diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index ab886e0..b876854 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -112,4 +112,6 @@ jobs: Automated benchmark refresh from `${{ github.workflow }}`. Source run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - add-paths: docs/benchmarks \ No newline at end of file + add-paths: | + README.md + docs/benchmarks \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index c25a165..48ae891 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,18 @@ dependencies = [ "libc", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "atoi_simd" version = "0.17.0" @@ -121,6 +133,15 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -145,6 +166,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crypto-common" version = "0.1.7" @@ -226,6 +253,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fast-float2" version = "0.2.4" @@ -269,6 +316,28 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "futures-task" version = "0.3.34" @@ -282,6 +351,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "slab", @@ -403,13 +473,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3afc87a40a375f5a85fdc749be750efebb789c4929a271fdea985cea22528fcd" dependencies = [ + "async-channel", "atomicwrites", "calamine", "chrono", "csv", "encoding_rs", "encoding_rs_io", + "event-listener", "fs2", + "futures-core", + "futures-util", "indexmap", "quick-xml", "rust_xlsxwriter", @@ -427,7 +501,15 @@ dependencies = [ name = "miniexcel-ffi" version = "0.1.0" dependencies = [ + "atomicwrites", + "chrono", + "futures-executor", + "futures-util", "miniexcel", + "quick-xml", + "serde_json", + "tempfile", + "zip", ] [[package]] @@ -445,6 +527,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "pin-project-lite" version = "0.2.17" diff --git a/README.md b/README.md index 5fe242c..de10977 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ [MiniExcel for Rust](https://github.com/mini-software/MiniExcel-Rust) through a small, versioned C ABI. -> This repository and package are experimental. The initial API supports synchronous, -> path-based dynamic XLSX queries. +> This repository and package are experimental. The current API supports Rust-backed XLSX and +> CSV reads and writes, bounded ranges, named tables, workbook metadata, templates, fluent +> mapping, and managed `DataTable`/`IDataReader` adapters. ## Install @@ -35,6 +36,8 @@ foreach (var row in MiniExcelRust.Query("input.xlsx", useHeaderRow: true)) { Console.WriteLine(row["Name"]); } + +var typedRows = MiniExcelRust.Query("input.xlsx"); ``` `Query` accepts `path`, `useHeaderRow`, `sheetName`, and `startCell`. Each streamed row is @@ -53,6 +56,66 @@ Rows are streamed in bounded batches across the native boundary. Disposing the e early closes the native query handle. Normal `foreach` enumeration disposes it automatically; code that manually obtains an enumerator should wrap it in `using`. +Additional read APIs include: + +```csharp +var names = MiniExcelRust.GetSheetNames("input.xlsx"); +var dimensions = MiniExcelRust.GetSheetDimensions("input.xlsx"); +var comments = MiniExcelRust.RetrieveComments("input.xlsx", "Data"); +var tableRows = MiniExcelRust.QueryTable("input.xlsx", "Data", "Table1"); +var rangeRows = MiniExcelRust.QueryRange("input.xlsx", true, "Data", "C2", "F100"); +var dataTable = MiniExcelRust.QueryAsDataTable("input.xlsx", hasHeaderRow: true); + +var written = MiniExcelRust.SaveAs( + "output.xlsx", + new[] + { + new Dictionary { ["Name"] = "alpha", ["Value"] = 42d } + }); + +var csvRows = MiniExcelRust.QueryCsv( + "input.csv", + useHeaderRow: true, + new MiniExcelRustCsvReadOptions { Delimiter = ';' }); + +MiniExcelRust.SaveAsCsv( + "output.csv", + new[] { new Dictionary { ["Name"] = "alpha" } }); + +MiniExcelRust.FillTemplate( + "report.xlsx", + "template.xlsx", + new { title = "Quarterly report", items = new[] { new { name = "Ada" } } }); +``` + +Fluent mapping supports exact cells, formats, formulas, spaced vertical collections, and nested +object collections. The same plan can export a workbook, read it back, or overlay values onto an +existing template while preserving unrelated package parts and existing target-cell styles: + +```csharp +var mapping = new MiniExcelRustMapping().ToWorksheet("Report"); +mapping.Property(report => report.Title).ToCell("A1"); +mapping.Collection(report => report.Items) + .StartAt("A3") + .WithSpacing(1) + .WithItemMapping(item => + { + item.Property(value => value.Name).ToCell("A3"); + item.Property(value => value.Amount).ToCell("B3").WithFormat("#,##0.00"); + }); + +MiniExcelRustMappingExtensions.ExportMapped("report.xlsx", reports, mapping); +var report = MiniExcelRustMappingExtensions.ReadMapped("report.xlsx", mapping); +MiniExcelRustMappingExtensions.FillMappedTemplate( + "filled.xlsx", "template.xlsx", reports, mapping); +``` + +Stream overloads stage input to a temporary file so the Rust engine can retain its bounded-memory +path iterator. They honor `leaveOpen` and remove the temporary file on completion, failure, or +early enumeration disposal. Native stream callbacks are planned to remove this staging step. + +See [the live parity matrix](docs/parity-matrix.md) for verified APIs and known gaps. + ## Supported Platforms | .NET RID | Operating system | Architecture | C library | @@ -83,11 +146,25 @@ dotnet build ./src/MiniExcelRust/MiniExcelRust.csproj -c Release ./build/Test-Package.ps1 -Rid win-x64 ``` +Use the local MiniExcel checkout as the read-only behavior oracle instead of the published package: + +```powershell +./build/Test-Package.ps1 -Rid win-x64 -MiniExcelSourceRoot D:\git\MiniExcel +``` + +The comments contract can also be checked against the shared Rust fixture after packing: + +```powershell +dotnet run --project .\tests\MiniExcelRust.PackageTests -c Release -- comments ` + D:\git\MiniExcel-Rust\tests\data\xlsx\TestCommentsAndNotes.xlsx sheet1 +``` + `Test-Package.ps1` builds the native library, packs `MiniExcelRust`, restores a separate consumer from the local package feed, and verifies equivalent queries against MiniExcel. -GitHub CI runs those header, headerless, sheet, start-cell, Unicode, boolean, null, numeric, -full-enumeration, and early-disposal queries on all eight supported RIDs. Each platform also +GitHub CI runs header, headerless, sheet, range, named-table, metadata, stream, CSV, Unicode, +boolean, null, numeric, full-enumeration, and early-disposal queries on all eight supported RIDs. +Each platform also runs 5,000 lifecycle iterations and fails when private memory grows by more than 32 MB, when the native handle/file-descriptor count grows by more than four, or when the workbook cannot be reopened exclusively. This is a bounded resource-growth regression test rather than a @@ -101,15 +178,26 @@ same generated XLSX file and query options. The scheduled and manually dispatche workflow runs on Windows, Linux, and macOS for x64 and Arm64; musl remains covered by the Alpine correctness and lifecycle job because GitHub does not provide native musl runners. -The latest checked-in cross-platform summary and per-RID reports are in the -[benchmark results](https://github.com/mini-software/MiniExcelRustNuGetTest/blob/main/docs/benchmarks/README.md). -Each report includes elapsed time, first-row latency, managed allocation, peak process memory, -environment metadata, and a JSON file containing all raw iterations and hashes. +### Latest Results + + +_Last updated (UTC): 2026-09-05 14:05:30_ + +| RID | Scenario | MiniExcel (ms) | MiniExcelRust (ms) | Speedup | Allocation reduction | Working-set reduction | +| --- | --- | ---: | ---: | ---: | ---: | ---: | +| win-x64 | Cold | 2891.79 | 1041.49 | 2.78x | 92.5% | 20.3% | +| win-x64 | Warm | 6136.27 | 2770.39 | 2.21x | 93.5% | 19.3% | + +[Full reports and raw results](https://github.com/mini-software/MiniExcelRustNuGetTest/blob/main/docs/benchmarks/README.md) + + +Each full report includes elapsed time, first-row latency, managed allocation, peak process +memory, environment metadata, and a JSON file containing all raw iterations and hashes. -After all scheduled benchmarks pass on the default branch, the workflow updates -`docs/benchmarks/` through an `automation/benchmark-results` pull request. Repeated runs refresh -the same PR instead of committing directly to the protected branch. Repository settings must -allow GitHub Actions to create pull requests. +After all scheduled benchmarks pass on the default branch, the workflow updates this summary +and `docs/benchmarks/` through an `automation/benchmark-results` pull request. Repeated runs +refresh the same PR instead of committing directly to the protected branch. Repository settings +must allow GitHub Actions to create pull requests. Run the same reproducible comparison locally, or override `-MiniExcelVersion` to test a newer NuGet release: diff --git a/build/Publish-Benchmark-Results.ps1 b/build/Publish-Benchmark-Results.ps1 index 63198af..4d5d13b 100644 --- a/build/Publish-Benchmark-Results.ps1 +++ b/build/Publish-Benchmark-Results.ps1 @@ -60,6 +60,7 @@ $published = foreach ($resultFile in $resultFiles) { } $latestTimestamp = ($published.TimestampUtc | Sort-Object -Descending | Select-Object -First 1) +$sortedPublished = @($published | Sort-Object Rid, Scenario) $index = [Collections.Generic.List[string]]::new() $index.Add('# Cross-platform benchmark results') $index.Add('') @@ -69,11 +70,38 @@ $index.Add('Each platform validates every returned row and cell before timing eq $index.Add('') $index.Add('| RID | Scenario | .NET runtime | MiniExcel | MiniExcel (ms) | MiniExcelRust (ms) | Speedup | Allocation reduction | Working-set reduction |') $index.Add('| --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: |') -foreach ($row in $published | Sort-Object Rid, Scenario) { +foreach ($row in $sortedPublished) { $index.Add("| [$($row.Rid)](benchmark-$($row.Rid).md) | $($row.Scenario) | $($row.DotNetRuntime) | $($row.MiniExcelVersion) | $($row.BaselineElapsedMs) | $($row.CandidateElapsedMs) | $($row.Speedup)x | $($row.AllocationReductionPercent)% | $($row.WorkingSetReductionPercent)% |") } $index.Add('') $index.Add('Managed allocation excludes allocations made inside Rust. Each linked report includes peak process memory and environment metadata; the adjacent JSON contains every raw iteration and input hash.') $index | Set-Content (Join-Path $OutputDirectory 'README.md') +$summary = [Collections.Generic.List[string]]::new() +$summary.Add("_Last updated (UTC): $($latestTimestamp.ToString('yyyy-MM-dd HH:mm:ss'))_") +$summary.Add('') +$summary.Add('| RID | Scenario | MiniExcel (ms) | MiniExcelRust (ms) | Speedup | Allocation reduction | Working-set reduction |') +$summary.Add('| --- | --- | ---: | ---: | ---: | ---: | ---: |') +foreach ($row in $sortedPublished) { + $summary.Add("| $($row.Rid) | $($row.Scenario) | $($row.BaselineElapsedMs) | $($row.CandidateElapsedMs) | $($row.Speedup)x | $($row.AllocationReductionPercent)% | $($row.WorkingSetReductionPercent)% |") +} +$summary.Add('') +$summary.Add('[Full reports and raw results](https://github.com/mini-software/MiniExcelRustNuGetTest/blob/main/docs/benchmarks/README.md)') + +$readmePath = Join-Path $repositoryRoot 'README.md' +$readme = Get-Content $readmePath -Raw +$startMarker = '' +$endMarker = '' +$startIndex = $readme.IndexOf($startMarker, [StringComparison]::Ordinal) +$endIndex = $readme.IndexOf($endMarker, [StringComparison]::Ordinal) +if ($startIndex -lt 0 -or $endIndex -le $startIndex) { + throw "README benchmark summary markers are missing or out of order: $readmePath" +} + +$endIndex += $endMarker.Length +$newLine = if ($readme.Contains("`r`n")) { "`r`n" } else { "`n" } +$summaryBlock = $startMarker + $newLine + ($summary -join $newLine) + $newLine + $endMarker +$updatedReadme = $readme.Substring(0, $startIndex) + $summaryBlock + $readme.Substring($endIndex) +[IO.File]::WriteAllText($readmePath, $updatedReadme) + Write-Host "Published $($resultFiles.Count) platform result set(s) to $OutputDirectory." \ No newline at end of file diff --git a/build/Test-Package.ps1 b/build/Test-Package.ps1 index ab5bb35..07efaba 100644 --- a/build/Test-Package.ps1 +++ b/build/Test-Package.ps1 @@ -7,6 +7,8 @@ param( [string] $MiniExcelVersion = '2.0.0-preview.4', + [string] $MiniExcelSourceRoot, + [switch] $SkipNativeBuild, [ValidateRange(100, 1000000)] @@ -20,6 +22,10 @@ $ErrorActionPreference = 'Stop' $repositoryRoot = Split-Path $PSScriptRoot -Parent $packageDirectory = Join-Path $repositoryRoot 'artifacts/packages' $consumerProject = Join-Path $repositoryRoot 'tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj' +$baselineProperties = @() +if ($MiniExcelSourceRoot) { + $baselineProperties += "-p:MiniExcelSourceRoot=$([System.IO.Path]::GetFullPath($MiniExcelSourceRoot))" +} if (-not $SkipNativeBuild) { & (Join-Path $PSScriptRoot 'Build-Native.ps1') -Rid $Rid @@ -52,7 +58,8 @@ finally { --force ` --source $packageDirectory ` -p:MiniExcelRustPackageVersion=$Version ` - -p:MiniExcelVersion=$MiniExcelVersion + -p:MiniExcelVersion=$MiniExcelVersion ` + @baselineProperties if ($LASTEXITCODE -ne 0) { throw 'Package consumer restore failed.' } @@ -60,6 +67,7 @@ if ($LASTEXITCODE -ne 0) { & dotnet run --project $consumerProject -c Release --no-restore ` -p:MiniExcelRustPackageVersion=$Version ` -p:MiniExcelVersion=$MiniExcelVersion ` + @baselineProperties ` -- suite $LifecycleIterations $MaxPrivateGrowthMb if ($LASTEXITCODE -ne 0) { throw 'Package consumer smoke test failed.' diff --git a/docs/miniexcel-rust-parity-plan.md b/docs/miniexcel-rust-parity-plan.md new file mode 100644 index 0000000..d87c00a --- /dev/null +++ b/docs/miniexcel-rust-parity-plan.md @@ -0,0 +1,253 @@ +# MiniExcel 全量 Rust 后端比对与迁移计划 + +## 1. 目标 + +在不修改 `D:\git\MiniExcel` 的前提下,以它的公开 API、单元测试和实际行为作为只读基准,补齐 `D:\git\MiniExcel-Rust` 的能力,并在当前 `D:\git\MiniExcelRust` .NET 包装仓库中提供兼容入口,使 MiniExcel 的 XLSX/CSV 解析、写入、模板和工作簿操作最终全部由 Rust 执行。 + +完成后的生产包不得依赖或回退到 C# MiniExcel 实现。反射、`DataTable`、`IDataReader`、`IAsyncEnumerable` 和 .NET 异常转换可以留在薄托管适配层,但文件格式处理、工作簿变更和模板执行必须进入 Rust。 + +## 2. 仓库边界 + +| 路径 | 角色 | 是否允许修改 | +| --- | --- | --- | +| `D:\git\MiniExcel` | C# API、行为和测试的基准实现 | 否,只读、可编译、可运行测试 | +| `D:\git\MiniExcel-Rust` | Rust 核心引擎 | 是,补齐底层能力和 Rust 测试 | +| `D:\git\MiniExcelRust` | 当前 .NET NuGet、FFI 和跨平台验证仓库 | 是,先在这里建立兼容层、比对测试和发布验证 | + +实施期间不得改写、格式化或提交 `D:\git\MiniExcel` 中的任何文件。需要的新 fixture、契约文件和差异测试应放入两个 Rust 相关仓库。 + +## 3. 完成定义 + +以下条件必须同时满足,才能称为“全部 MiniExcel 方法底层换成 Rust”: + +1. 公开 API 清单中的每个方法和重载都有对应实现,包含同步、异步、路径、流和 `byte[]` 变体。 +2. 方法名称、泛型约束、参数名称、默认值、返回类型和可观察异常与基准版本兼容。 +3. 生产依赖图中不存在 C# MiniExcel 包或程序集;差异测试项目可以仅把它作为基准 oracle 使用。 +4. XLSX/CSV 的读取、写入、模板、图片和工作簿变更均由 Rust 引擎执行。 +5. `D:\git\MiniExcel` 的适用单元测试已在只读目录原样通过;迁移后的兼容测试与差异测试也全部通过。 +6. Windows、Linux、macOS 的 x64/Arm64 包测试通过;musl 平台继续通过正确性和资源生命周期测试。 +7. 提前停止枚举、取消、异常、重复调用和流所有权测试证明没有句柄、文件描述符或非托管内存持续增长。 + +基准版本应在执行开始时记录提交 SHA、包版本和公开 API 快照。基准升级必须单独评审,不能在迁移过程中无提示漂移。 + +## 4. API 比对清单 + +先生成机器可读矩阵,建议字段为:`API ID`、基准签名、Rust 能力、FFI 能力、托管入口、同步测试、异步测试、路径测试、流测试、异常测试、状态和备注。 + +### 4.1 OpenXML 读取 + +- [ ] `Query` / `QueryAsync`:动态、泛型,路径和流。 +- [ ] `QueryRange` / `QueryRangeAsync`:A1 地址与行列索引重载。 +- [ ] `QueryTable` / `QueryTableAsync`:动态、泛型,路径和流。 +- [ ] `QueryAsDataTable` / `QueryAsDataTableAsync`。 +- [ ] `GetReader` / `GetDataReader` / `GetAsyncDataReader`。 +- [ ] `GetSheetNames`、`GetSheetInformations`、`GetSheetDimensions`、`GetColumns` / `GetColumnNames`。 +- [ ] `RetrieveComments`,包括批注、作者和回复。 +- [ ] 延迟枚举、取消、空行、稀疏单元格、合并单元格填充、表头裁剪和共享字符串缓存。 + +### 4.2 OpenXML 写入与工作簿操作 + +- [ ] `SaveAs` / `Export`:路径和流,同步和异步。 +- [ ] POCO、匿名对象、字典、`DataTable`、`DataSet`、`IDataReader`、`IAsyncEnumerable` 和多工作表输入。 +- [ ] `Insert` / `InsertSheet`:新增或替换工作表。 +- [ ] `CopyAndAddSheet`:文件到文件、流到流。 +- [ ] `AlterSheet`:重命名、排序和可见状态。 +- [ ] 自动筛选、冻结窗格、RTL、列宽、隐藏列、换行、对齐、表头样式、日期及数字格式。 +- [ ] 覆盖策略、进度回报、原子输出和失败后的目标文件状态。 + +### 4.3 模板与富内容 + +- [ ] `SaveAsByTemplate` / `FillTemplate`:所有路径、流和 `byte[]` 组合。 +- [ ] 标量替换、集合展开、分组、条件、公式、行移动和缺失变量策略。 +- [ ] `MergeSameCells`:合并标记、边界和既有合并区域。 +- [ ] `AddPicture`:图片类型、尺寸、锚点、关系和内容类型。 +- [ ] 模板处理后保持公式、合并区域、表格、批注、定义名称和绘图关系有效。 + +### 4.4 CSV 与格式转换 + +- [ ] CSV `Query`、`QueryAsDataTable`、`GetColumnNames` 和 Reader API。 +- [ ] CSV `Export` / `SaveAs` 与 `Append`。 +- [ ] 分隔符、换行符、引号、嵌入换行、BOM、空字符串、编码和自定义 reader/writer。 +- [ ] `ConvertCsvToXlsx` 和 `ConvertXlsxToCsv` 的路径与流、同步与异步重载。 + +### 4.5 映射、配置和兼容入口 + +- [ ] `MiniExcelLibs.MiniExcel` 旧版 facade 的全部方法与重载。 +- [ ] V2 `Importers`、`Exporters`、`Templaters` provider API。 +- [ ] 列名称、索引、宽度、格式、隐藏、忽略、sheet 等 attributes。 +- [ ] nullable、enum、GUID、URI、日期、`DateOnly`、`DateTimeOffset`、`TimeSpan`、culture 和自定义格式转换。 +- [ ] Fluent Mapping:`Property`、`Collection`、`ToWorksheet`、`ToCell`、`WithFormat`、`WithFormula`、`StartAt`、`WithSpacing` 和嵌套集合。 +- [ ] `ExcelType`、configuration、model、enum、exception 和 compatibility alias。 +- [ ] stream `leaveOpen`、seek 要求、overwrite 默认值和参数验证行为。 + +## 5. 当前差距摘要 + +| 能力 | `MiniExcel-Rust` 核心 | 当前 .NET FFI/包装 | 主要工作 | +| --- | --- | --- | --- | +| 动态 XLSX 查询 | 已有较完整能力 | 仅同步路径查询 | 补齐 options、范围、流、异步和错误语义 | +| 泛型映射 | Rust Serde 已有基础 | 未暴露 | 建立 schema/列映射协议和 .NET 转换层 | +| metadata/table/comments | Rust 已有 | 未暴露 | 增加 ABI 和托管模型 | +| CSV | Rust 已有读写与 append | 未暴露 | 增加流式 ABI、配置和转换入口 | +| XLSX 写入 | Rust 已有基础与多 sheet 能力 | 未暴露 | 增加 schema、输入回调、进度和原子输出 | +| insert/copy/rename/reorder/visibility | Rust 已有部分能力 | 未暴露 | 增加 package 保真与回滚测试 | +| template/merge | Rust 已有基础 | 未暴露且与 C# 仍有差距 | 补齐集合、关系和公式更新语义 | +| picture | 未完整支持 | 未暴露 | 实现 OOXML drawing、media 和 relationship 写入 | +| `DataTable`/`IDataReader` | 不属于 Rust 类型系统 | 未实现 | 在托管层适配到统一 Rust row/schema 协议 | +| Fluent Mapping | 有 cell map,但不等价 | 未实现 | 托管层生成 mapping plan,Rust 执行读取/写入 | +| async/cancellation | 原生 async 有部分能力 | 未暴露 | 增加取消句柄、异步流和线程规则 | + +当前 ABI v1 还会把 Rust `i64` 转成 `double`、把 Excel error 转成普通字符串,并把 duration 截断到毫秒;这些都必须在兼容工作开始前修正。 + +## 6. 实施阶段 + +### 阶段 0:冻结基准与建立矩阵 + +1. 记录三个仓库的 commit SHA、工具链版本和目标框架。 +2. 用反射生成 `D:\git\MiniExcel` 公开 API 快照,包括生成出的同步方法。 +3. 从 OpenXML、CSV、Fluent Mapping、legacy facade 测试中建立测试对应表。 +4. 将必要 fixture 复制到 Rust 相关仓库,记录来源与预期 hash;不修改原 fixture。 +5. 建立“缺失、签名不符、行为不符、已通过”四种状态的 API 矩阵。 + +退出条件:所有公开方法都有唯一 API ID 和至少一个计划中的验收测试。 + +### 阶段 1:稳定 ABI 与资源模型 + +1. 发布版本化 C header/protocol,定义长度、所有权、线程、取消和错误规则。 +2. 把返回值升级为可扩展 tagged value,保留 `Int64`、日期时间精度、Excel error 和 null/empty 差异。 +3. 为 path、borrowed stream callback、owned buffer、row iterator 和 writer 建立独立句柄。 +4. 提供结构化 error code、错误类别、参数名和内部消息,不允许 panic 跨越 FFI。 +5. 增加 ABI test vectors、畸形 frame 测试、重复 close 和提前 dispose 测试。 +6. 消除当前仓库与上游重复 FFI 源码漂移:改为单一来源或加入自动同步校验。 + +退出条件:ABI 契约测试、内存/句柄生命周期测试和八个 RID 的加载测试通过。 + +### 阶段 2:完成 XLSX 读取 + +1. 补齐 `ReadOptions`、end cell、空行、merged fill、trim header、缓存与 sheet 选择。 +2. 暴露 range、table、metadata、column、dimension 和 comments API。 +3. 实现 path、stream、`byte[]` 统一读取源;严格落实 `leaveOpen`。 +4. 完成 dynamic row 的列名、顺序、值类型和异常一致性。 +5. 增加 `IEnumerable` / `IAsyncEnumerable`、取消和提前结束枚举。 + +退出条件:读取类差异测试逐行、逐列、逐类型一致,相关 OpenXML 基准测试全部通过。 + +### 阶段 3:类型映射、配置与 Reader + +1. 托管层把 attributes、reflection 和 fluent mapping 编译为稳定 schema/mapping plan。 +2. Rust 按 plan 完成列定位、值转换和错误定位;托管层只构造对象或适配 .NET 类型。 +3. 在同一 native row iterator 上实现 `IDataReader`、async reader 和 `DataTable` adapter。 +4. 补齐 culture、日期、enum、nullable、字段、动态列和映射异常。 + +退出条件:typed mapping、DataReader、DataTable、Fluent Mapping 的读取测试全部通过。 + +### 阶段 4:CSV 与转换 + +1. 暴露 CSV query、reader、export、append 和完整配置。 +2. 覆盖 UTF-8/UTF-16/GBK/Windows-1252、BOM、quote 和跨行字段。 +3. 让 CSV/XLSX 转换直接串接 Rust reader/writer,避免整表进入托管内存。 + +退出条件:CSV 单元测试、CsvHelper 互操作测试和转换 round-trip 测试通过。 + +### 阶段 5:XLSX 写入 + +1. 先实现 dynamic/schema 单 sheet,再支持 typed、异步输入和多 sheet。 +2. 托管输入统一转换为 row/schema callback,覆盖所有支持的数据源类型。 +3. 补齐 style、format、width、hidden、freeze、filter、RTL 和 progress。 +4. 对 path 使用临时文件加原子替换;stream 失败时定义并测试可观察状态。 +5. 使用 Excel、EPPlus、ClosedXML、NPOI、ExcelDataReader 或 Packaging 验证输出。 + +退出条件:写入结果可被基准读取且关键 OOXML 结构等价,所有 export 测试通过。 + +### 阶段 6:工作簿变更、模板与图片 + +1. 完成 insert、copy/add、rename、reorder 和 visibility,并保持无关 package parts 不变。 +2. 补齐模板集合、分组、条件、公式、行偏移、merge 和 missing-value 行为。 +3. 实现 picture 写入及 drawing relationship/content type 管理。 +4. 对公式引用、calc chain、table range、defined names、merge、comments 和 drawings 建立变更后校验。 +5. `.xlsm` 按基准行为拒绝可能丢失宏的写入操作,不静默降级。 + +退出条件:模板、图片、工作簿变更和第三方互操作测试全部通过。 + +### 阶段 7:完整 facade 与发布门禁 + +1. 将 provider API 和 legacy `MiniExcelLibs.MiniExcel` 的全部重载接到 Rust-backed 实现。 +2. 用 API snapshot/approval test 阻止遗漏重载、默认值或 public type。 +3. 删除任何生产环境 C# MiniExcel fallback 和临时双实现开关。 +4. 执行全量单元、差异、互操作、压力、泄漏和跨平台 package 测试。 +5. 对每个 API ID 关闭矩阵条目;不得以“Rust 暂不支持”跳过完成门槛。 + +退出条件:API 矩阵 100% 完成,生产依赖检查为纯 Rust 后端,发布包全平台验证通过。 + +## 7. 测试策略 + +### 7.1 三层测试 + +1. Rust 单元/集成测试:验证 parser、writer、template、package mutation 和错误路径。 +2. .NET 兼容测试:验证签名、默认值、attributes、reflection、Reader、DataTable、异步和异常。 +3. 黑盒差异测试:同一 fixture、参数和 culture 分别运行 C# 基准与 Rust-backed 包,对结果、异常和输出文件进行标准化比对。 + +不要只比较成功结果。每个 API 至少覆盖正常、边界、错误、取消或提前结束中的适用场景。 + +### 7.2 比对规则 + +- 查询:比较 sheet、row、column、key 顺序、CLR 类型和值;浮点、日期和 duration 使用明确规则。 +- 异常:比较异常类别、触发时机、参数名和关键消息,不依赖平台路径文本。 +- XLSX:先比较语义,再检查关键 OOXML parts、relationships、content types 和未变更 part 的 hash。 +- CSV:比较编码后的 bytes、BOM、换行、引用和尾部换行。 +- 流:覆盖 seekable/non-seekable、只读/只写、`leaveOpen` 和中途异常。 +- 性能:正确性优先;通过后要求流式操作保持有界内存,且不得比 C# 基准出现未解释的数量级退化。 + +### 7.3 必须纳入的回归类别 + +- Header/headerless、Unicode、空白与重复表头、稀疏 XML、缺失 `r` attribute。 +- 数字精度、bool、null/empty、日期、时间、时长、公式 cached value 和 Excel error。 +- 多 sheet、隐藏 sheet、table、comments/replies、merged cells 和 shared strings。 +- 泛型映射、attributes、culture、nullable、enum、GUID、URI 和 mapping failure。 +- 大文件、提前停止、取消、并发、重复 5,000 次生命周期和目标文件独占重开。 +- 模板分组、公式、图片、insert/copy/rename/reorder 及第三方软件打开验证。 +- `D:\git\MiniExcel\tests` 下 `Issues` 目录中的历史回归案例。 + +## 8. 建议验证命令 + +只读基准: + +```powershell +dotnet test D:\git\MiniExcel\tests\MiniExcel.OpenXml.Tests\MiniExcel.OpenXml.Tests.csproj --framework net10.0 +dotnet test D:\git\MiniExcel\tests\MiniExcel.Csv.Tests\MiniExcel.Csv.Tests.csproj --framework net10.0 +``` + +Rust 核心: + +```powershell +Set-Location D:\git\MiniExcel-Rust +cargo +1.85.0 fmt --all -- --check +cargo +1.85.0 clippy --workspace --all-targets --all-features --locked -- -D warnings +cargo +1.85.0 test --workspace --all-targets --all-features --locked +cargo +1.85.0 doc --workspace --no-deps --all-features --locked +``` + +当前 .NET 包装与 NuGet: + +```powershell +Set-Location D:\git\MiniExcelRust +cargo test --workspace --all-targets --locked +dotnet build .\src\MiniExcelRust\MiniExcelRust.csproj -c Release +.\build\Test-Package.ps1 -Rid win-x64 +``` + +CI 中再扩展到所有目标 RID,并把 API snapshot、差异测试、依赖检查和 package 内容校验设为必过门禁。 + +## 9. 执行顺序与交付物 + +每个阶段使用同一节奏:先补失败的契约测试,再实现 Rust 核心,再扩展 ABI 和托管入口,最后跑差异与跨平台 package 测试。避免先批量声明全部 .NET 方法再长期保留 `NotSupportedException`。 + +阶段性交付物如下: + +- `api-baseline.json`:C# 基准公开 API 快照。 +- `parity-matrix.md` 或结构化等价文件:逐 API 状态和测试证据。 +- 版本化 ABI 文档与 test vectors。 +- 可复用的差异测试 runner 和标准化比较器。 +- 各阶段 Rust、.NET、互操作和资源测试报告。 +- 最终生产依赖报告,证明没有 C# MiniExcel runtime fallback。 + +首个实现批次应从阶段 0、阶段 1 和 XLSX 动态读取差距开始,不应直接进入模板或图片功能;先稳定 ABI,后续方法才能共用同一套流、错误、值和生命周期协议。 \ No newline at end of file diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md new file mode 100644 index 0000000..3d39703 --- /dev/null +++ b/docs/parity-matrix.md @@ -0,0 +1,59 @@ +# MiniExcel Rust parity matrix + +Baseline captured on 2026-09-05: + +- C# source: `D:\git\MiniExcel`, commit `5de51f7fc0edd99388faeb72184e3f5af80d1374`. +- Rust source: `D:\git\MiniExcel-Rust`, commit `905ad6189da92f91221798996ad2c837fb083147`. +- Package-test base: `D:\git\MiniExcelRust`, commit `12890be18a837e8dbe5babee3a040bae9ab347ae`. +- NuGet oracle used by package tests: MiniExcel `2.0.0-preview.4`. + +Run against the checked-out source without modifying it: + +```powershell +.\build\Test-Package.ps1 -Rid win-x64 -MiniExcelSourceRoot D:\git\MiniExcel +``` + +Status meanings: **Verified** has a managed-vs-Rust package test; **Partial** works with documented +limits; **Missing** has no production implementation yet. + +| Area | API/capability | Status | Evidence or remaining work | +| --- | --- | --- | --- | +| XLSX read | Dynamic `Query`, path | Verified | Header/headerless, sheet, start cell, scalar values, Unicode | +| XLSX read | Dynamic `Query`, stream | Verified | `leaveOpen` and early disposal; currently stages to a temp file | +| XLSX read | `QueryRange`, path/stream | Verified | Inclusive A1 end cell | +| XLSX read | `ReadOptions` | Verified | Header/trim, missing and self-closing rows, physical merged-cell presence and shared-cache options covered | +| XLSX read | `QueryTable`, path/stream | Verified | Named table and case-insensitive table name | +| Metadata | Sheet names | Verified | Path, stream, sync and task-based async | +| Metadata | Column names | Verified | Header/headerless, sheet and start cell | +| Metadata | Sheet dimensions | Verified | Path/stream, declared ranges and missing-dimension parity | +| Metadata | Sheet information | Verified | ID, index, name, hidden state, active state and sheet type | +| Adapters | `QueryAsDataTable` | Verified | Single selected sheet; materialized managed adapter | +| Adapters | `GetReader` | Verified | Selected/all sheets, path/stream, materialized rows and `NextResult` verified | +| Async | Metadata tasks | Partial | Runs Rust operation on a worker; no in-flight native cancellation | +| Async | `IAsyncEnumerable` query | Partial | XLSX/CSV path/stream dynamic/typed/table enumeration plus pre-cancellation verified; in-flight native batch cancellation remains | +| Typed read/write | POCO/attribute mapping | Verified | Properties/fields, index, readonly export, rename/alias/ignore, GUID, enum, nullable, culture, localization, dynamic overrides/formatters, ExcelFormat, errors and formula columns verified | +| Comments | Notes/threaded comments | Verified | Path/stream, authors, timestamps, replies, resolved state and legacy notes | +| CSV read | Dynamic query, path/stream | Verified | Header, delimiter, BOM, Unicode, quoted text and empty string | +| CSV metadata | Column names | Verified | Path, stream, sync and task-based async | +| CSV adapters | DataTable/Reader | Verified | Materialized managed adapters | +| Conversion | CSV/XLSX, path/stream | Verified | Both directions, header behavior and task wrappers verified | +| CSV write | Dynamic/typed save/append, path/stream | Verified | Delimiter, BOM, header, overwrite, append, bounded native spool and in-flight cancellation verified | +| XLSX write | Dynamic single-sheet `SaveAs`, path/stream | Partial | Scalars, temporal values, schema and full exposed styles verified; per-cell completion progress works, in-flight native progress remains | +| XLSX write | Dynamic multi-sheet, path/stream | Verified | Ordered sheets, per-sheet row counts, content and overwrite verified | +| XLSX write | Typed/async export | Verified | POCO attributes/scalars, bounded spool, native one-pass consumption, progress and in-flight cancellation verified | +| Workbook edits | Rename/reorder/visibility | Verified | Atomic path operations checked through C# and Rust metadata readers | +| Workbook edits | Dynamic insert/copy-and-add, path | Partial | Add/reject/replace/source preservation verified; stream and complex relationship policies remain | +| Templates | Path/stream/byte[] fill | Partial | All source/destination combinations, scalars, list expansion, strict missing variables and overwrite verified; advanced parity remains | +| Templates | `MergeSameCells`, path/stream/byte[] | Verified | Merge refs, marker removal, source preservation and overwrite verified | +| Pictures | AddPicture | Verified | PNG path/stream, OneCell/Absolute/TwoCell anchors, repeated drawing updates and package preservation verified | +| Fluent mapping | Exact-cell reads | Verified | Rust `CellMap` path/stream dynamic and typed adapters verified | +| Fluent mapping | Collections/write/template | Verified | Properties, formats, per-cell formulas, vertical spacing, nested collections, path/stream/async export and read, and style-preserving mapped templates verified; defined layouts are compared with the source oracle | +| Legacy facade | `MiniExcelLibs.MiniExcel` | Partial | Sync/async path/stream groups, ExcelType/configuration routing, object/POCO/dictionary/DataTable/DataSet writes and conversions delegate only to Rust; exact model/overload parity remains | + +## Confirmed differences + +1. CSV empty-as-null: the checked-out C# source applies the setting, while the pinned + `2.0.0-preview.4` NuGet oracle returned an empty string in the exercised dynamic query. + +These rows remain open until the intended C# source contract is selected and encoded as explicit +compatibility behavior. They must not be hidden by loosening equality assertions. \ No newline at end of file diff --git a/native/miniexcel-ffi/Cargo.toml b/native/miniexcel-ffi/Cargo.toml index ae5a13e..5a66f0b 100644 --- a/native/miniexcel-ffi/Cargo.toml +++ b/native/miniexcel-ffi/Cargo.toml @@ -13,4 +13,14 @@ publish = false crate-type = ["cdylib"] [dependencies] -miniexcel = "=0.4.0" +chrono = "0.4.45" +futures-executor = "0.3" +futures-util = "0.3" +miniexcel = { version = "=0.4.0", features = ["async"] } +quick-xml = "0.39.4" +serde_json = "1.0" +tempfile = "3.27.0" +zip = { version = "7.2.0", default-features = false, features = ["deflate"] } + +[target.'cfg(windows)'.dependencies] +atomicwrites = "0.4.4" diff --git a/native/miniexcel-ffi/src/lib.rs b/native/miniexcel-ffi/src/lib.rs index 93413d4..7922228 100644 --- a/native/miniexcel-ffi/src/lib.rs +++ b/native/miniexcel-ffi/src/lib.rs @@ -1,10 +1,25 @@ use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; use std::ffi::{CStr, c_char}; +use std::fs::File; +use std::io::{BufReader, ErrorKind, Read, Write}; use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::Path; use std::ptr; use std::str::FromStr; -use miniexcel::{CellReference, CellValue, DynamicRow, HeaderMode, MiniExcel, ReadOptions}; +use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime}; +use miniexcel::{ + CellMap, CellReference, CellValue, CommentPerson, CommentTimestamp, CsvConfiguration, + CsvEncoding, CsvReadOptions, CsvWriteOptions, DynamicRow, ExistingSheetPolicy, HeaderMode, + HeaderStyle, HorizontalAlignment, InsertOptions, MergeSameCellsOptions, MiniExcel, ReadOptions, + RgbColor, SheetType, SheetVisibility, TableStyle, TargetRelationshipPolicy, TemplateOptions, + VerticalAlignment, WriteOptions, +}; +use quick_xml::Reader as XmlReader; +use quick_xml::events::{BytesStart, Event}; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; const ABI_VERSION: u32 = 1; const RESULT_END: i32 = 0; @@ -12,6 +27,7 @@ const RESULT_BATCH: i32 = 1; const ERROR_INVALID_ARGUMENT: i32 = -1; const ERROR_QUERY: i32 = -2; const ERROR_PANIC: i32 = -3; +const ERROR_WRITE: i32 = -4; thread_local! { static LAST_ERROR: RefCell> = const { RefCell::new(Vec::new()) }; @@ -22,6 +38,194 @@ pub struct QueryHandle { frame: Vec, } +struct PhysicalRowIterator { + inner: Box> + Send>, + pattern: std::vec::IntoIter, + columns: Vec, + start_column: usize, + normalize_merged_cells: bool, + merged_ranges: Vec, + merge_anchor_values: Vec>, +} + +enum PhysicalRowAction { + Data { row: usize, columns: Vec }, + Empty, + Skip, +} + +#[derive(Clone, Copy)] +struct MergedRangeInfo { + start_row: usize, + start_column: usize, + end_row: usize, + end_column: usize, +} + +impl Iterator for PhysicalRowIterator { + type Item = miniexcel::Result; + + fn next(&mut self) -> Option { + for action in self.pattern.by_ref() { + match action { + PhysicalRowAction::Empty => { + let row = self + .columns + .iter() + .cloned() + .map(|column| (column, CellValue::Empty)) + .collect(); + return Some(Ok(row)); + } + PhysicalRowAction::Data { + row: row_index, + columns: physical_columns, + } => { + let mut row = self.inner.next()?; + if self.normalize_merged_cells { + if let Ok(row) = row.as_mut() { + for (offset, (_, value)) in row.iter_mut().enumerate() { + let column = self.start_column + offset; + if !physical_columns.contains(&column) { + *value = CellValue::Empty; + } + } + for (index, range) in self.merged_ranges.iter().enumerate() { + if row_index == range.start_row { + let offset = + range.start_column.saturating_sub(self.start_column); + self.merge_anchor_values[index] = + row.get_index(offset).map(|(_, value)| value.clone()); + } + if row_index < range.start_row || row_index > range.end_row { + continue; + } + let Some(anchor) = self.merge_anchor_values[index].as_ref() else { + continue; + }; + for column in range.start_column..=range.end_column { + if column == range.start_column && row_index == range.start_row + { + continue; + } + if physical_columns.contains(&column) { + let offset = column.saturating_sub(self.start_column); + if let Some((_, value)) = row.get_index_mut(offset) { + if value.is_empty() { + *value = anchor.clone(); + } + } + } + } + } + } + } + return Some(row); + } + PhysicalRowAction::Skip => { + if let Err(error) = self.inner.next()? { + return Some(Err(error)); + } + } + } + } + self.inner.next() + } +} + +pub struct BufferHandle { + frame: Vec, +} + +pub struct CancellationHandle { + token: miniexcel::CancellationToken, +} + +struct QueryOpenOptions { + path: *const c_char, + use_header_row: u8, + sheet_name: *const c_char, + start_cell: *const c_char, + end_cell: *const c_char, + ignore_empty_rows: u8, + fill_merged_cells: u8, + trim_headers: u8, + enable_shared_string_cache: u8, + shared_string_cache_size: u64, + shared_string_cache_path: *const c_char, +} + +struct CsvWriteArguments { + path: *const c_char, + data: *const u8, + data_length: usize, + delimiter: u8, + encoding: u8, + write_bom: u8, + print_header: u8, + overwrite_file: u8, +} + +struct SpoolRows { + reader: BufReader, + finished: bool, +} + +impl SpoolRows { + fn open(path: impl AsRef) -> std::io::Result { + Ok(Self { + reader: BufReader::new(File::open(path)?), + finished: false, + }) + } +} + +impl Iterator for SpoolRows { + type Item = miniexcel::Result; + + fn next(&mut self) -> Option { + if self.finished { + return None; + } + let mut length = [0_u8; 4]; + if let Err(error) = self.reader.read_exact(&mut length) { + self.finished = true; + return if error.kind() == ErrorKind::UnexpectedEof { + None + } else { + Some(Err(error.into())) + }; + } + let length = u32::from_le_bytes(length) as usize; + let mut frame = vec![0_u8; length]; + if let Err(error) = self.reader.read_exact(&mut frame) { + self.finished = true; + return Some(Err(error.into())); + } + match decode_rows(&frame) { + Ok(mut rows) if rows.len() == 1 => Some(Ok(rows.remove(0))), + Ok(_) => { + self.finished = true; + Some(Err(std::io::Error::new( + ErrorKind::InvalidData, + "spool frame must contain exactly one row", + ) + .into())) + } + Err(_) => { + self.finished = true; + Some(Err(std::io::Error::new( + ErrorKind::InvalidData, + "spool row frame is invalid", + ) + .into())) + } + } + } +} + +type DeclaredDimension = (Option, Option); + #[unsafe(no_mangle)] pub extern "C" fn miniexcel_abi_version() -> u32 { ABI_VERSION @@ -40,36 +244,184 @@ pub unsafe extern "C" fn miniexcel_query_open( sheet_name: *const c_char, start_cell: *const c_char, out_handle: *mut *mut QueryHandle, +) -> i32 { + ffi_result(|| unsafe { + open_query( + QueryOpenOptions { + path, + use_header_row, + sheet_name, + start_cell, + end_cell: ptr::null(), + ignore_empty_rows: 0, + fill_merged_cells: 0, + trim_headers: 1, + enable_shared_string_cache: 1, + shared_string_cache_size: 5 * 1024 * 1024, + shared_string_cache_path: ptr::null(), + }, + out_handle, + ) + }) +} + +/// Opens a bounded path-based XLSX query and returns an opaque native handle. +/// +/// # Safety +/// +/// String pointers must be null-terminated UTF-8. `path`, `start_cell`, and `out_handle` must be +/// non-null and valid for the duration of the call. `sheet_name` and `end_cell` may be null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_query_range_open( + path: *const c_char, + use_header_row: u8, + sheet_name: *const c_char, + start_cell: *const c_char, + end_cell: *const c_char, + out_handle: *mut *mut QueryHandle, +) -> i32 { + ffi_result(|| unsafe { + open_query( + QueryOpenOptions { + path, + use_header_row, + sheet_name, + start_cell, + end_cell, + ignore_empty_rows: 0, + fill_merged_cells: 0, + trim_headers: 1, + enable_shared_string_cache: 1, + shared_string_cache_size: 5 * 1024 * 1024, + shared_string_cache_path: ptr::null(), + }, + out_handle, + ) + }) +} + +/// Opens a configured path-based XLSX query and returns an opaque native handle. +/// +/// # Safety +/// +/// Required string and output pointers must be non-null and valid for the duration of the call. +/// `sheet_name`, `end_cell`, and `shared_string_cache_path` may be null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_query_options_open( + path: *const c_char, + use_header_row: u8, + sheet_name: *const c_char, + start_cell: *const c_char, + end_cell: *const c_char, + ignore_empty_rows: u8, + fill_merged_cells: u8, + trim_headers: u8, + enable_shared_string_cache: u8, + shared_string_cache_size: u64, + shared_string_cache_path: *const c_char, + out_handle: *mut *mut QueryHandle, +) -> i32 { + ffi_result(|| unsafe { + open_query( + QueryOpenOptions { + path, + use_header_row, + sheet_name, + start_cell, + end_cell, + ignore_empty_rows, + fill_merged_cells, + trim_headers, + enable_shared_string_cache, + shared_string_cache_size, + shared_string_cache_path, + }, + out_handle, + ) + }) +} + +/// Opens a path-based query over a named OpenXML table. +/// +/// # Safety +/// +/// `path`, `table_name`, and `out_handle` must be non-null and valid for the duration of the call. +/// `sheet_name` may be null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_query_table_open( + path: *const c_char, + sheet_name: *const c_char, + table_name: *const c_char, + out_handle: *mut *mut QueryHandle, ) -> i32 { ffi_result(|| { - if path.is_null() || start_cell.is_null() || out_handle.is_null() { - set_last_error("path, start_cell, and out_handle are required"); + if path.is_null() || table_name.is_null() || out_handle.is_null() { + set_last_error("path, table_name, and out_handle are required"); return Err(ERROR_INVALID_ARGUMENT); } + unsafe { ptr::write(out_handle, ptr::null_mut()) }; let path = unsafe { read_utf8(path) }?; - let start_cell = unsafe { read_utf8(start_cell) }?; - let start_cell = CellReference::from_str(start_cell).map_err(|error| { + let table_name = unsafe { read_utf8(table_name) }?; + if table_name.is_empty() { + set_last_error("table_name cannot be empty"); + return Err(ERROR_INVALID_ARGUMENT); + } + let sheet_name = if sheet_name.is_null() { + None + } else { + let value = unsafe { read_utf8(sheet_name) }?; + (!value.is_empty()).then_some(value) + }; + + let rows = MiniExcel::query_table(path, table_name, sheet_name).map_err(|error| { set_last_error(error.to_string()); - ERROR_INVALID_ARGUMENT + ERROR_QUERY })?; + let handle = Box::new(QueryHandle { + rows, + frame: Vec::new(), + }); + unsafe { ptr::write(out_handle, Box::into_raw(handle)) }; + Ok(RESULT_BATCH) + }) +} - let mut options = ReadOptions::new() - .with_header_mode(if use_header_row == 0 { - HeaderMode::None - } else { - HeaderMode::FirstRow - }) - .with_start_cell(start_cell); - - if !sheet_name.is_null() { - let sheet_name = unsafe { read_utf8(sheet_name) }?; - if !sheet_name.is_empty() { - options = options.with_sheet_name(sheet_name); - } +/// Opens a path-based CSV query using explicit read options. +/// +/// # Safety +/// +/// `path` and `out_handle` must be non-null and valid for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_query_csv_open( + path: *const c_char, + use_header_row: u8, + delimiter: u8, + encoding: u8, + read_empty_as_null: u8, + trim_headers: u8, + out_handle: *mut *mut QueryHandle, +) -> i32 { + ffi_result(|| { + if path.is_null() || out_handle.is_null() { + set_last_error("path and out_handle are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + if delimiter == 0 { + set_last_error("delimiter must be a single-byte character"); + return Err(ERROR_INVALID_ARGUMENT); } - let rows = MiniExcel::query_with_options(path, &options).map_err(|error| { + unsafe { ptr::write(out_handle, ptr::null_mut()) }; + let path = unsafe { read_utf8(path) }?; + let options = csv_read_options( + use_header_row, + delimiter, + encoding, + read_empty_as_null, + trim_headers, + )?; + let rows = MiniExcel::query_csv_with_options(path, &options).map_err(|error| { set_last_error(error.to_string()); ERROR_QUERY })?; @@ -82,6 +434,59 @@ pub unsafe extern "C" fn miniexcel_query_open( }) } +/// Returns selected CSV column names through an owned metadata buffer. +/// +/// # Safety +/// +/// `path` and all output pointers must be non-null and valid for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_get_csv_columns( + path: *const c_char, + use_header_row: u8, + delimiter: u8, + encoding: u8, + read_empty_as_null: u8, + trim_headers: u8, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() || out_handle.is_null() || out_data.is_null() || out_length.is_null() { + set_last_error("path, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); + } + + let path = unsafe { read_utf8(path) }?; + let options = csv_read_options( + use_header_row, + delimiter, + encoding, + read_empty_as_null, + trim_headers, + )?; + let columns = MiniExcel::get_csv_columns(path, &options).map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let handle = Box::new(BufferHandle { + frame: write_strings(columns)?, + }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) + }) +} + /// Writes the next bounded batch into memory owned by the query handle. /// /// # Safety @@ -147,101 +552,2944 @@ pub unsafe extern "C" fn miniexcel_query_close(handle: *mut QueryHandle) { } } -/// Returns the last error recorded on the current native thread. +/// Returns worksheet names in workbook order using memory owned by an opaque buffer handle. /// /// # Safety /// -/// `out_length` may be null; otherwise it must be writable. The returned data remains valid until -/// the next MiniExcel FFI error on this thread. +/// `path`, `out_handle`, `out_data`, and `out_length` must be non-null and valid for the duration +/// of the call. Returned data remains valid until `miniexcel_buffer_close` closes the handle. #[unsafe(no_mangle)] -pub unsafe extern "C" fn miniexcel_last_error(out_length: *mut usize) -> *const u8 { - LAST_ERROR.with(|error| { - let error = error.borrow(); - if !out_length.is_null() { - unsafe { ptr::write(out_length, error.len()) }; +pub unsafe extern "C" fn miniexcel_get_sheet_names( + path: *const c_char, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() || out_handle.is_null() || out_data.is_null() || out_length.is_null() { + set_last_error("path, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); } - error.as_ptr() - }) -} -fn ffi_result(operation: impl FnOnce() -> Result) -> i32 { - match catch_unwind(AssertUnwindSafe(operation)) { - Ok(Ok(result)) => result, - Ok(Err(code)) => code, - Err(_) => { - set_last_error("Rust panic crossed the MiniExcel FFI boundary"); - ERROR_PANIC + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); } - } -} -unsafe fn read_utf8<'a>(value: *const c_char) -> Result<&'a str, i32> { - unsafe { CStr::from_ptr(value) }.to_str().map_err(|error| { - set_last_error(error.to_string()); - ERROR_INVALID_ARGUMENT + let path = unsafe { read_utf8(path) }?; + let names = MiniExcel::get_sheet_names(path).map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let mut frame = Vec::new(); + write_length(&mut frame, names.len())?; + for name in names { + write_string(&mut frame, name)?; + } + + let handle = Box::new(BufferHandle { frame }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) }) } -fn set_last_error(message: impl AsRef) { - LAST_ERROR.with(|error| { - let mut error = error.borrow_mut(); - error.clear(); - error.extend_from_slice(message.as_ref().as_bytes()); - }); -} +/// Returns selected column names using memory owned by an opaque buffer handle. +/// +/// # Safety +/// +/// `path`, `start_cell`, and all output pointers must be non-null and valid for the duration of +/// the call. `sheet_name` may be null. Returned data remains valid until the handle is closed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_get_columns( + path: *const c_char, + use_header_row: u8, + sheet_name: *const c_char, + start_cell: *const c_char, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() + || start_cell.is_null() + || out_handle.is_null() + || out_data.is_null() + || out_length.is_null() + { + set_last_error("path, start_cell, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); + } -fn write_row(frame: &mut Vec, row: &DynamicRow) -> Result<(), i32> { - write_length(frame, row.len())?; - for (name, value) in row { - write_string(frame, name)?; - match value { - CellValue::Empty => frame.push(0), - CellValue::Bool(value) => { - frame.push(1); - frame.push(u8::from(*value)); - } - CellValue::Int(value) => { - frame.push(2); - frame.extend_from_slice(&value.to_le_bytes()); - } - CellValue::Float(value) => { - frame.push(3); - frame.extend_from_slice(&value.to_le_bytes()); + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); + } + + let path = unsafe { read_utf8(path) }?; + let start_cell = unsafe { read_utf8(start_cell) }?; + let start_cell = CellReference::from_str(start_cell).map_err(|error| { + set_last_error(error.to_string()); + ERROR_INVALID_ARGUMENT + })?; + let mut options = ReadOptions::new() + .with_header_mode(if use_header_row == 0 { + HeaderMode::None + } else { + HeaderMode::FirstRow + }) + .with_start_cell(start_cell); + + if !sheet_name.is_null() { + let sheet_name = unsafe { read_utf8(sheet_name) }?; + if !sheet_name.is_empty() { + options = options.with_sheet_name(sheet_name); } - CellValue::String(value) => { - frame.push(4); + } + + let columns = MiniExcel::get_columns(path, &options).map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let handle = Box::new(BufferHandle { + frame: write_strings(columns)?, + }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) + }) +} + +/// Returns worksheet dimensions as optional A1 start/end address pairs. +/// +/// # Safety +/// +/// `path` and all output pointers must be non-null and valid for the duration of the call. +/// Returned data remains valid until the buffer handle is closed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_get_sheet_dimensions( + path: *const c_char, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() || out_handle.is_null() || out_data.is_null() || out_length.is_null() { + set_last_error("path, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); + } + + let path = unsafe { read_utf8(path) }?; + let dimensions = declared_sheet_dimensions(path)?; + let mut frame = Vec::new(); + write_length(&mut frame, dimensions.len())?; + for (start_cell, end_cell) in dimensions { + write_string(&mut frame, start_cell.unwrap_or_default())?; + write_string(&mut frame, end_cell.unwrap_or_default())?; + } + + let handle = Box::new(BufferHandle { frame }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) + }) +} + +/// Returns worksheet metadata in workbook order. +/// +/// # Safety +/// +/// `path` and all output pointers must be non-null and valid for the duration of the call. +/// Returned data remains valid until the buffer handle is closed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_get_sheet_info( + path: *const c_char, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() || out_handle.is_null() || out_data.is_null() || out_length.is_null() { + set_last_error("path, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); + } + + let path = unsafe { read_utf8(path) }?; + let sheets = MiniExcel::get_sheet_info(path).map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let mut frame = Vec::new(); + write_length(&mut frame, sheets.len())?; + for sheet in sheets { + write_u32(&mut frame, sheet.id()); + write_length(&mut frame, sheet.index())?; + write_string(&mut frame, sheet.name())?; + frame.push(match sheet.sheet_type() { + SheetType::Worksheet => 0, + SheetType::DialogSheet => 1, + SheetType::MacroSheet => 2, + SheetType::ChartSheet => 3, + SheetType::Vba => 4, + }); + frame.push(match sheet.visibility() { + SheetVisibility::Visible => 0, + SheetVisibility::Hidden => 1, + SheetVisibility::VeryHidden => 2, + }); + frame.push(u8::from(sheet.is_active())); + } + + let handle = Box::new(BufferHandle { frame }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) + }) +} + +/// Returns threaded comments, replies, and legacy notes for a worksheet. +/// +/// # Safety +/// +/// `path` and all output pointers must be non-null and valid for the duration of the call. +/// `sheet_name` may be null. Returned data remains valid until the buffer handle is closed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_get_comments( + path: *const c_char, + sheet_name: *const c_char, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() || out_handle.is_null() || out_data.is_null() || out_length.is_null() { + set_last_error("path, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); + } + + let path = unsafe { read_utf8(path) }?; + let sheet_name = if sheet_name.is_null() { + None + } else { + let value = unsafe { read_utf8(sheet_name) }?; + (!value.is_empty()).then_some(value) + }; + let comments = MiniExcel::get_comments(path, sheet_name).map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let mut frame = Vec::new(); + write_string(&mut frame, comments.sheet_name())?; + write_length(&mut frame, comments.threaded_comments().len())?; + for comment in comments.threaded_comments() { + write_string(&mut frame, comment.id().to_string())?; + write_string(&mut frame, comment.cell().to_string())?; + write_person(&mut frame, comment.person())?; + write_timestamp(&mut frame, comment.created_at())?; + frame.push(u8::from(comment.resolved())); + write_string(&mut frame, comment.text())?; + write_length(&mut frame, comment.replies().len())?; + for reply in comment.replies() { + write_string(&mut frame, reply.id().to_string())?; + write_string(&mut frame, reply.parent_id().to_string())?; + write_person(&mut frame, reply.person())?; + write_timestamp(&mut frame, reply.created_at())?; + write_string(&mut frame, reply.text())?; + } + } + write_length(&mut frame, comments.notes().len())?; + for note in comments.notes() { + write_optional_string(&mut frame, note.id().map(|id| id.to_string()).as_deref())?; + write_string(&mut frame, note.cell().to_string())?; + write_optional_string(&mut frame, note.author())?; + write_string(&mut frame, note.text())?; + } + + let handle = Box::new(BufferHandle { frame }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) + }) +} + +/// Reads explicitly mapped worksheet cells into one dynamic row. +/// +/// # Safety +/// +/// `path`, `mapping_data`, and all output pointers must be valid for supplied lengths. +/// `sheet_name` may be null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_read_mapped( + path: *const c_char, + sheet_name: *const c_char, + mapping_data: *const u8, + mapping_length: usize, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() + || mapping_data.is_null() + || out_handle.is_null() + || out_data.is_null() + || out_length.is_null() + { + set_last_error("path, mapping_data, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); + } + let path = unsafe { read_utf8(path) }?; + let mut reader = + FrameInput::new(unsafe { std::slice::from_raw_parts(mapping_data, mapping_length) }); + let count = reader.read_length()?; + let mut mapping = CellMap::new(); + let mut fields = Vec::with_capacity(count); + if !sheet_name.is_null() { + let sheet_name = unsafe { read_utf8(sheet_name) }?; + if !sheet_name.is_empty() { + mapping = mapping.with_sheet_name(sheet_name); + } + } + for _ in 0..count { + let field = reader.read_string()?; + let cell = CellReference::from_str(&reader.read_string()?).map_err(|error| { + set_last_error(error.to_string()); + ERROR_INVALID_ARGUMENT + })?; + mapping = mapping.with_cell(&field, cell); + fields.push(field); + } + reader.ensure_complete()?; + let mut values = + MiniExcel::read_mapped_as::>(path, &mapping) + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let mut row = DynamicRow::with_capacity(fields.len()); + for field in fields { + let value = values.remove(&field).unwrap_or(serde_json::Value::Null); + row.insert(field, json_value_to_cell(value)?); + } + let mut frame = Vec::new(); + write_u32(&mut frame, 1); + write_row(&mut frame, &row)?; + let handle = Box::new(BufferHandle { frame }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) + }) +} + +fn json_value_to_cell(value: serde_json::Value) -> Result { + match value { + serde_json::Value::Null => Ok(CellValue::Empty), + serde_json::Value::Bool(value) => Ok(CellValue::Bool(value)), + serde_json::Value::Number(value) => { + if let Some(integer) = value.as_i64() { + Ok(CellValue::Int(integer)) + } else if let Some(unsigned) = value.as_u64() { + i64::try_from(unsigned).map(CellValue::Int).map_err(|_| { + set_last_error("mapped unsigned integer exceeds Int64"); + ERROR_QUERY + }) + } else { + value.as_f64().map(CellValue::Float).ok_or_else(|| { + set_last_error("mapped JSON number is not representable"); + ERROR_QUERY + }) + } + } + serde_json::Value::String(value) => Ok(CellValue::String(value)), + _ => { + set_last_error("mapped cell produced a non-scalar JSON value"); + Err(ERROR_QUERY) + } + } +} + +/// Creates a single-sheet XLSX workbook from encoded dynamic rows. +/// +/// # Safety +/// +/// `path`, `data`, and `out_row_count` must be non-null and valid for the supplied lengths. +/// `sheet_name` may be null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_save_as( + path: *const c_char, + data: *const u8, + data_length: usize, + print_header: u8, + sheet_name: *const c_char, + overwrite_file: u8, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| { + if path.is_null() || data.is_null() || out_row_count.is_null() { + set_last_error("path, data, and out_row_count are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + + unsafe { ptr::write(out_row_count, 0) }; + let path = unsafe { read_utf8(path) }?; + let bytes = unsafe { std::slice::from_raw_parts(data, data_length) }; + let rows = decode_rows(bytes)?; + let mut options = WriteOptions::new() + .with_print_header(print_header != 0) + .with_overwrite_file(overwrite_file != 0); + if !sheet_name.is_null() { + let sheet_name = unsafe { read_utf8(sheet_name) }?; + if !sheet_name.is_empty() { + options = options.with_sheet_name(sheet_name); + } + } + MiniExcel::save_as_with_options(path, &rows, &options).map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + let row_count = u32::try_from(rows.len()).map_err(|_| { + set_last_error("row count exceeds the ABI limit"); + ERROR_WRITE + })?; + unsafe { ptr::write(out_row_count, row_count) }; + Ok(RESULT_BATCH) + }) +} + +/// Creates a multi-sheet XLSX workbook from an ordered encoded sheet collection. +/// +/// # Safety +/// +/// `path`, `data`, and all output pointers must be valid for the supplied lengths. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_save_as_sheets( + path: *const c_char, + data: *const u8, + data_length: usize, + print_header: u8, + overwrite_file: u8, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() + || data.is_null() + || out_handle.is_null() + || out_data.is_null() + || out_length.is_null() + { + set_last_error("path, data, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); + } + let path = unsafe { read_utf8(path) }?; + let sheets = decode_sheets(unsafe { std::slice::from_raw_parts(data, data_length) })?; + let options = WriteOptions::new() + .with_print_header(print_header != 0) + .with_overwrite_file(overwrite_file != 0); + let counts = MiniExcel::save_as_sheets( + path, + sheets.iter().map(|(name, rows)| (name, rows.as_slice())), + &options, + ) + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + let mut frame = Vec::new(); + write_length(&mut frame, counts.len())?; + for count in counts { + write_length(&mut frame, count)?; + } + let handle = Box::new(BufferHandle { frame }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) + }) +} + +/// Creates an XLSX workbook from dynamic rows and a JSON write-options payload. +/// +/// # Safety +/// +/// `path`, `data`, `options_json`, and `out_row_count` must be valid for supplied lengths. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_save_as_configured( + path: *const c_char, + data: *const u8, + data_length: usize, + options_json: *const u8, + options_length: usize, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| { + if path.is_null() || data.is_null() || options_json.is_null() || out_row_count.is_null() { + set_last_error("path, data, options_json, and out_row_count are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { ptr::write(out_row_count, 0) }; + let path = unsafe { read_utf8(path) }?; + let mut rows = decode_rows(unsafe { std::slice::from_raw_parts(data, data_length) })?; + let payload: serde_json::Value = serde_json::from_slice(unsafe { + std::slice::from_raw_parts(options_json, options_length) + }) + .map_err(|error| { + set_last_error(format!("invalid write-options JSON: {error}")); + ERROR_INVALID_ARGUMENT + })?; + let options = configured_write_options(&payload)?; + let schema = configured_schema(&payload)?; + let formula_columns = configured_formula_columns(&payload)?; + if formula_columns.is_empty() { + write_configured_workbook(path, &rows, schema.as_deref(), &options)?; + } else { + for row in &mut rows { + for column in &formula_columns { + if let Some(value) = row.get_mut(column) { + let CellValue::String(formula) = value else { + set_last_error(format!( + "formula column '{column}' requires string values" + )); + return Err(ERROR_INVALID_ARGUMENT); + }; + let formula = formula.strip_prefix('=').unwrap_or(formula); + *value = CellValue::String(format!("$={formula}")); + } + } + } + let destination = Path::new(path); + let parent = destination.parent().unwrap_or_else(|| Path::new(".")); + let staging = tempfile::Builder::new() + .prefix(".miniexcel-formula-") + .suffix(".xlsx") + .tempfile_in(parent) + .map_err(write_error)? + .into_temp_path(); + std::fs::remove_file(&staging).map_err(write_error)?; + let staging_options = options.clone().with_overwrite_file(false); + let staging_path: &Path = staging.as_ref(); + write_configured_workbook(staging_path, &rows, schema.as_deref(), &staging_options)?; + let template_options = TemplateOptions::new() + .with_overwrite_file(json_bool(&payload, "overwriteFile", false)?) + .with_ignore_missing_variables(true); + MiniExcel::save_as_template(path, &staging, &serde_json::json!({}), &template_options) + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + } + write_row_count(rows.len(), out_row_count) + }) +} + +/// Creates a cancellable XLSX export by consuming framed rows from a spool file once. +/// +/// # Safety +/// +/// All pointers must be non-null, valid, and remain alive for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_save_as_spooled_async( + path: *const c_char, + spool_path: *const c_char, + options_json: *const u8, + options_length: usize, + cancellation: *mut CancellationHandle, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| { + if path.is_null() + || spool_path.is_null() + || options_json.is_null() + || cancellation.is_null() + || out_row_count.is_null() + { + set_last_error( + "path, spool_path, options_json, cancellation, and out_row_count are required", + ); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { ptr::write(out_row_count, 0) }; + let path = unsafe { read_utf8(path) }?; + let spool_path = unsafe { read_utf8(spool_path) }?; + let payload: serde_json::Value = serde_json::from_slice(unsafe { + std::slice::from_raw_parts(options_json, options_length) + }) + .map_err(|error| { + set_last_error(format!("invalid write-options JSON: {error}")); + ERROR_INVALID_ARGUMENT + })?; + let schema = configured_schema(&payload)?.ok_or_else(|| { + set_last_error("async spool export requires an explicit schema"); + ERROR_INVALID_ARGUMENT + })?; + let options = configured_write_options(&payload)?; + let rows = SpoolRows::open(spool_path).map_err(write_error)?; + let rows = futures_util::stream::iter(rows); + let token = unsafe { &*cancellation }.token.clone(); + let count = + futures_executor::block_on(MiniExcel::save_as_with_schema_async_with_cancellation( + path, &schema, rows, &options, token, + )) + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + write_row_count(count, out_row_count) + }) +} + +/// Creates a cancellable CSV export by consuming framed rows from a spool file once. +/// +/// # Safety +/// +/// All pointers must be non-null, valid, and remain alive for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_save_csv_spooled_async( + path: *const c_char, + spool_path: *const c_char, + options_json: *const u8, + options_length: usize, + cancellation: *mut CancellationHandle, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| { + if path.is_null() + || spool_path.is_null() + || options_json.is_null() + || cancellation.is_null() + || out_row_count.is_null() + { + set_last_error( + "path, spool_path, options_json, cancellation, and out_row_count are required", + ); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { ptr::write(out_row_count, 0) }; + let path = unsafe { read_utf8(path) }?; + let spool_path = unsafe { read_utf8(spool_path) }?; + let payload: serde_json::Value = serde_json::from_slice(unsafe { + std::slice::from_raw_parts(options_json, options_length) + }) + .map_err(|error| { + set_last_error(format!("invalid CSV write-options JSON: {error}")); + ERROR_INVALID_ARGUMENT + })?; + let schema = configured_schema(&payload)?.ok_or_else(|| { + set_last_error("async CSV spool export requires an explicit schema"); + ERROR_INVALID_ARGUMENT + })?; + let configuration = CsvConfiguration::new() + .with_delimiter( + json_u64(&payload, "delimiter", b',' as u64)? + .try_into() + .map_err(|_| invalid_write_options("delimiter exceeds one byte"))?, + ) + .with_encoding(parse_csv_encoding( + json_u64(&payload, "encoding", 0)? + .try_into() + .map_err(|_| invalid_write_options("encoding exceeds one byte"))?, + )?) + .with_write_bom(json_bool(&payload, "writeBom", true)?); + let options = CsvWriteOptions::new() + .with_configuration(configuration) + .with_print_header(json_bool(&payload, "printHeader", true)?) + .with_overwrite_file(true); + let overwrite = json_bool(&payload, "overwriteFile", false)?; + let destination = Path::new(path); + if destination.exists() && !overwrite { + set_last_error(format!( + "destination '{}' already exists", + destination.display() + )); + return Err(ERROR_WRITE); + } + let parent = destination.parent().unwrap_or_else(|| Path::new(".")); + let staging = tempfile::Builder::new() + .prefix(".miniexcel-csv-") + .suffix(".csv") + .tempfile_in(parent) + .map_err(write_error)? + .into_temp_path(); + let staging_path: &Path = staging.as_ref(); + let token = unsafe { &*cancellation }.token.clone(); + let rows = SpoolRows::open(spool_path).map_err(write_error)?; + let mut count = 0_usize; + for row in rows { + if token.is_cancelled() { + set_last_error("operation cancelled"); + return Err(ERROR_WRITE); + } + let row = row.map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + if count == 0 { + MiniExcel::save_csv_with_schema(staging_path, &schema, &[row], &options) + } else { + MiniExcel::append_csv_with_schema(staging_path, &schema, &[row], &options) + } + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + count += 1; + } + if count == 0 { + MiniExcel::save_csv_with_schema(staging_path, &schema, &[], &options).map_err( + |error| { + set_last_error(error.to_string()); + ERROR_WRITE + }, + )?; + } + if token.is_cancelled() { + set_last_error("operation cancelled"); + return Err(ERROR_WRITE); + } + publish_staged_file(staging_path, destination)?; + write_row_count(count, out_row_count) + }) +} + +#[unsafe(no_mangle)] +/// Creates a native cooperative cancellation handle. +/// +/// # Safety +/// +/// `out_handle` must be non-null and writable. +pub unsafe extern "C" fn miniexcel_cancellation_create( + out_handle: *mut *mut CancellationHandle, +) -> i32 { + ffi_result(|| { + if out_handle.is_null() { + set_last_error("out_handle is required"); + return Err(ERROR_INVALID_ARGUMENT); + } + let handle = Box::new(CancellationHandle { + token: miniexcel::CancellationToken::new(), + }); + unsafe { ptr::write(out_handle, Box::into_raw(handle)) }; + Ok(RESULT_BATCH) + }) +} + +#[unsafe(no_mangle)] +/// Signals cooperative cancellation. +/// +/// # Safety +/// +/// `handle` must be null or a live cancellation handle returned by this library. +pub unsafe extern "C" fn miniexcel_cancellation_cancel(handle: *mut CancellationHandle) { + if !handle.is_null() { + unsafe { &*handle }.token.cancel(); + } +} + +#[unsafe(no_mangle)] +/// Releases a native cancellation handle. +/// +/// # Safety +/// +/// `handle` must be null or a live cancellation handle that has not already been closed. +pub unsafe extern "C" fn miniexcel_cancellation_close(handle: *mut CancellationHandle) { + if !handle.is_null() { + let _ = catch_unwind(AssertUnwindSafe(|| drop(unsafe { Box::from_raw(handle) }))); + } +} + +/// Creates a CSV file from encoded dynamic rows. +/// +/// # Safety +/// +/// `path`, `data`, and `out_row_count` must be non-null and valid for the supplied lengths. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_save_csv( + path: *const c_char, + data: *const u8, + data_length: usize, + delimiter: u8, + encoding: u8, + write_bom: u8, + print_header: u8, + overwrite_file: u8, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| unsafe { + write_csv( + CsvWriteArguments { + path, + data, + data_length, + delimiter, + encoding, + write_bom, + print_header, + overwrite_file, + }, + false, + out_row_count, + ) + }) +} + +/// Appends encoded dynamic rows to a CSV file. +/// +/// # Safety +/// +/// `path`, `data`, and `out_row_count` must be non-null and valid for the supplied lengths. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_append_csv( + path: *const c_char, + data: *const u8, + data_length: usize, + delimiter: u8, + encoding: u8, + write_bom: u8, + print_header: u8, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| unsafe { + write_csv( + CsvWriteArguments { + path, + data, + data_length, + delimiter, + encoding, + write_bom, + print_header, + overwrite_file: 0, + }, + true, + out_row_count, + ) + }) +} + +/// Inserts or replaces a worksheet in an XLSX workbook. +/// +/// # Safety +/// +/// `path`, `data`, `sheet_name`, and `out_row_count` must be valid for the supplied lengths. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_insert_sheet( + path: *const c_char, + data: *const u8, + data_length: usize, + sheet_name: *const c_char, + print_header: u8, + replace_existing: u8, + remove_supported_relationships: u8, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| { + if path.is_null() || data.is_null() || sheet_name.is_null() || out_row_count.is_null() { + set_last_error("path, data, sheet_name, and out_row_count are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { ptr::write(out_row_count, 0) }; + let path = unsafe { read_utf8(path) }?; + let sheet_name = unsafe { read_utf8(sheet_name) }?; + let rows = decode_rows(unsafe { std::slice::from_raw_parts(data, data_length) })?; + let options = insert_options( + sheet_name, + print_header, + replace_existing, + remove_supported_relationships, + false, + ); + let count = MiniExcel::insert(path, &rows, &options).map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + write_row_count(count, out_row_count) + }) +} + +/// Copies an XLSX workbook and adds or replaces one worksheet in the destination. +/// +/// # Safety +/// +/// Both paths, `data`, `sheet_name`, and `out_row_count` must be valid for supplied lengths. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_copy_and_add_sheet( + source_path: *const c_char, + destination_path: *const c_char, + data: *const u8, + data_length: usize, + sheet_name: *const c_char, + print_header: u8, + replace_existing: u8, + remove_supported_relationships: u8, + overwrite_destination: u8, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| { + if source_path.is_null() + || destination_path.is_null() + || data.is_null() + || sheet_name.is_null() + || out_row_count.is_null() + { + set_last_error( + "source_path, destination_path, data, sheet_name, and out_row_count are required", + ); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { ptr::write(out_row_count, 0) }; + let source_path = unsafe { read_utf8(source_path) }?; + let destination_path = unsafe { read_utf8(destination_path) }?; + let sheet_name = unsafe { read_utf8(sheet_name) }?; + let rows = decode_rows(unsafe { std::slice::from_raw_parts(data, data_length) })?; + let options = insert_options( + sheet_name, + print_header, + replace_existing, + remove_supported_relationships, + overwrite_destination != 0, + ); + let count = MiniExcel::copy_and_add_sheet(source_path, destination_path, &rows, &options) + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + write_row_count(count, out_row_count) + }) +} + +/// Fills an XLSX template from a UTF-8 JSON value and atomically writes the destination. +/// +/// # Safety +/// +/// All string pointers must be non-null, valid, null-terminated UTF-8 for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_fill_template( + destination_path: *const c_char, + template_path: *const c_char, + json_data: *const u8, + json_length: usize, + overwrite_file: u8, + ignore_missing_variables: u8, +) -> i32 { + ffi_result(|| { + if destination_path.is_null() || template_path.is_null() || json_data.is_null() { + set_last_error("destination_path, template_path, and json_data are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + + let destination_path = unsafe { read_utf8(destination_path) }?; + let template_path = unsafe { read_utf8(template_path) }?; + let json = unsafe { std::slice::from_raw_parts(json_data, json_length) }; + let value: serde_json::Value = serde_json::from_slice(json).map_err(|error| { + set_last_error(format!("invalid template JSON: {error}")); + ERROR_INVALID_ARGUMENT + })?; + let options = TemplateOptions::new() + .with_overwrite_file(overwrite_file != 0) + .with_ignore_missing_variables(ignore_missing_variables != 0); + MiniExcel::save_as_template(destination_path, template_path, &value, &options).map_err( + |error| { + set_last_error(error.to_string()); + ERROR_WRITE + }, + )?; + Ok(RESULT_BATCH) + }) +} + +/// Overlays an expanded fluent-mapping cell plan onto an XLSX template. +/// +/// # Safety +/// +/// All string pointers must be non-null, valid, null-terminated UTF-8 for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_fill_mapped_template( + destination_path: *const c_char, + template_path: *const c_char, + json_data: *const u8, + json_length: usize, + overwrite_file: u8, +) -> i32 { + ffi_result(|| { + if destination_path.is_null() || template_path.is_null() || json_data.is_null() { + set_last_error("destination_path, template_path, and json_data are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + let destination_path = unsafe { read_utf8(destination_path) }?; + let template_path = unsafe { read_utf8(template_path) }?; + let payload: serde_json::Value = + serde_json::from_slice(unsafe { std::slice::from_raw_parts(json_data, json_length) }) + .map_err(|error| { + set_last_error(format!("invalid mapped template JSON: {error}")); + ERROR_INVALID_ARGUMENT + })?; + fill_mapped_template( + destination_path, + template_path, + &payload, + overwrite_file != 0, + )?; + Ok(RESULT_BATCH) + }) +} + +/// Merges tagged same-value cells into a separate XLSX destination. +/// +/// # Safety +/// +/// Both path pointers must be non-null, valid, null-terminated UTF-8 for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_merge_same_cells( + destination_path: *const c_char, + source_path: *const c_char, + overwrite_file: u8, +) -> i32 { + ffi_result(|| { + if destination_path.is_null() || source_path.is_null() { + set_last_error("destination_path and source_path are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + let destination_path = unsafe { read_utf8(destination_path) }?; + let source_path = unsafe { read_utf8(source_path) }?; + let options = MergeSameCellsOptions::new().with_overwrite_file(overwrite_file != 0); + MiniExcel::merge_same_cells(source_path, destination_path, &options).map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + Ok(RESULT_BATCH) + }) +} + +/// Adds one PNG picture to an existing XLSX workbook. +/// +/// # Safety +/// +/// String and image pointers must be valid for the supplied lengths. `sheet_name` may be null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_add_picture( + path: *const c_char, + sheet_name: *const c_char, + cell_address: *const c_char, + image_data: *const u8, + image_length: usize, + width_px: u32, + height_px: u32, + anchor_type: u8, + location_x: i32, + location_y: i32, +) -> i32 { + ffi_result(|| { + if path.is_null() || cell_address.is_null() || image_data.is_null() { + set_last_error("path, cell_address, and image_data are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + if image_length == 0 || width_px == 0 || height_px == 0 { + set_last_error("image data, width, and height must be non-zero"); + return Err(ERROR_INVALID_ARGUMENT); + } + let path = unsafe { read_utf8(path) }?; + let sheet_name = if sheet_name.is_null() { + None + } else { + let value = unsafe { read_utf8(sheet_name) }?; + (!value.is_empty()).then_some(value) + }; + let cell_address = unsafe { read_utf8(cell_address) }?; + let image = unsafe { std::slice::from_raw_parts(image_data, image_length) }; + add_png_picture( + path, + sheet_name, + cell_address, + image, + width_px, + height_px, + anchor_type, + location_x, + location_y, + )?; + Ok(RESULT_BATCH) + }) +} + +/// Atomically renames a worksheet in an existing XLSX workbook. +/// +/// # Safety +/// +/// All string pointers must be non-null, valid, null-terminated UTF-8 for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_rename_sheet( + path: *const c_char, + sheet_name: *const c_char, + new_sheet_name: *const c_char, +) -> i32 { + ffi_result(|| { + if path.is_null() || sheet_name.is_null() || new_sheet_name.is_null() { + set_last_error("path, sheet_name, and new_sheet_name are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + let path = unsafe { read_utf8(path) }?; + let sheet_name = unsafe { read_utf8(sheet_name) }?; + let new_sheet_name = unsafe { read_utf8(new_sheet_name) }?; + MiniExcel::rename_sheet(path, sheet_name, new_sheet_name).map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + Ok(RESULT_BATCH) + }) +} + +/// Atomically moves a worksheet to a zero-based index. +/// +/// # Safety +/// +/// Both string pointers must be non-null, valid, null-terminated UTF-8 for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_reorder_sheet( + path: *const c_char, + sheet_name: *const c_char, + new_sheet_index: i32, +) -> i32 { + ffi_result(|| { + if path.is_null() || sheet_name.is_null() { + set_last_error("path and sheet_name are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + let path = unsafe { read_utf8(path) }?; + let sheet_name = unsafe { read_utf8(sheet_name) }?; + MiniExcel::reorder_sheet(path, sheet_name, new_sheet_index).map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + Ok(RESULT_BATCH) + }) +} + +/// Atomically changes a worksheet visibility state. +/// +/// # Safety +/// +/// Both string pointers must be non-null, valid, null-terminated UTF-8 for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_set_sheet_visibility( + path: *const c_char, + sheet_name: *const c_char, + visibility: u8, +) -> i32 { + ffi_result(|| { + if path.is_null() || sheet_name.is_null() { + set_last_error("path and sheet_name are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + let visibility = match visibility { + 0 => SheetVisibility::Visible, + 1 => SheetVisibility::Hidden, + 2 => SheetVisibility::VeryHidden, + _ => { + set_last_error("visibility is not supported"); + return Err(ERROR_INVALID_ARGUMENT); + } + }; + let path = unsafe { read_utf8(path) }?; + let sheet_name = unsafe { read_utf8(sheet_name) }?; + MiniExcel::set_sheet_visibility(path, sheet_name, visibility).map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + Ok(RESULT_BATCH) + }) +} + +/// Releases a buffer returned by a metadata operation. +/// +/// # Safety +/// +/// `handle` must be null or a handle returned by this library that has not already been closed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_buffer_close(handle: *mut BufferHandle) { + if !handle.is_null() { + let _ = catch_unwind(AssertUnwindSafe(|| drop(unsafe { Box::from_raw(handle) }))); + } +} + +/// Returns the last error recorded on the current native thread. +/// +/// # Safety +/// +/// `out_length` may be null; otherwise it must be writable. The returned data remains valid until +/// the next MiniExcel FFI error on this thread. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_last_error(out_length: *mut usize) -> *const u8 { + LAST_ERROR.with(|error| { + let error = error.borrow(); + if !out_length.is_null() { + unsafe { ptr::write(out_length, error.len()) }; + } + error.as_ptr() + }) +} + +fn ffi_result(operation: impl FnOnce() -> Result) -> i32 { + match catch_unwind(AssertUnwindSafe(operation)) { + Ok(Ok(result)) => result, + Ok(Err(code)) => code, + Err(_) => { + set_last_error("Rust panic crossed the MiniExcel FFI boundary"); + ERROR_PANIC + } + } +} + +unsafe fn read_utf8<'a>(value: *const c_char) -> Result<&'a str, i32> { + unsafe { CStr::from_ptr(value) }.to_str().map_err(|error| { + set_last_error(error.to_string()); + ERROR_INVALID_ARGUMENT + }) +} + +unsafe fn open_query( + arguments: QueryOpenOptions, + out_handle: *mut *mut QueryHandle, +) -> Result { + let QueryOpenOptions { + path, + use_header_row, + sheet_name, + start_cell, + end_cell, + ignore_empty_rows, + fill_merged_cells, + trim_headers, + enable_shared_string_cache, + shared_string_cache_size, + shared_string_cache_path, + } = arguments; + if path.is_null() || start_cell.is_null() || out_handle.is_null() { + set_last_error("path, start_cell, and out_handle are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + + unsafe { ptr::write(out_handle, ptr::null_mut()) }; + let path = unsafe { read_utf8(path) }?; + let start_cell_text = unsafe { read_utf8(start_cell) }?; + let start_row = cell_row_index(start_cell_text)?; + let start_column = cell_column_index(start_cell_text)?; + let start_cell = CellReference::from_str(start_cell_text).map_err(|error| { + set_last_error(error.to_string()); + ERROR_INVALID_ARGUMENT + })?; + + let mut options = ReadOptions::new() + .with_header_mode(if use_header_row == 0 { + HeaderMode::None + } else { + HeaderMode::FirstRow + }) + .with_start_cell(start_cell) + .with_ignore_empty_rows(ignore_empty_rows != 0) + .with_fill_merged_cells(fill_merged_cells != 0) + .with_trim_headers(trim_headers != 0) + .with_shared_string_disk_cache(enable_shared_string_cache != 0) + .with_shared_string_cache_size(shared_string_cache_size); + + let mut end_row = None; + if !end_cell.is_null() { + let end_cell_text = unsafe { read_utf8(end_cell) }?; + end_row = Some(cell_row_index(end_cell_text)?); + let end_cell = CellReference::from_str(end_cell_text).map_err(|error| { + set_last_error(error.to_string()); + ERROR_INVALID_ARGUMENT + })?; + options = options.with_end_cell(end_cell); + } + + let mut selected_sheet_name = None; + if !sheet_name.is_null() { + let sheet_name = unsafe { read_utf8(sheet_name) }?; + if !sheet_name.is_empty() { + selected_sheet_name = Some(sheet_name.to_owned()); + options = options.with_sheet_name(sheet_name); + } + } + + if !shared_string_cache_path.is_null() { + let cache_path = unsafe { read_utf8(shared_string_cache_path) }?; + if !cache_path.is_empty() { + options = options.with_shared_string_cache_path(cache_path); + } + } + + let rows = MiniExcel::query_with_options(path, &options).map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let rows: Box> + Send> = + if ignore_empty_rows != 0 { + let columns = MiniExcel::get_columns(path, &options).map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let (mut pattern, merged_ranges) = worksheet_physical_row_pattern( + path, + selected_sheet_name.as_deref(), + start_row, + end_row, + )?; + if use_header_row != 0 { + if let Some(header_index) = pattern + .iter() + .position(|action| matches!(action, PhysicalRowAction::Data { .. })) + { + pattern.remove(header_index); + } + } + Box::new(PhysicalRowIterator { + inner: rows, + pattern: pattern.into_iter(), + columns, + start_column, + normalize_merged_cells: fill_merged_cells != 0, + merge_anchor_values: vec![None; merged_ranges.len()], + merged_ranges, + }) + } else { + rows + }; + let handle = Box::new(QueryHandle { + rows, + frame: Vec::new(), + }); + unsafe { ptr::write(out_handle, Box::into_raw(handle)) }; + Ok(RESULT_BATCH) +} + +fn set_last_error(message: impl AsRef) { + LAST_ERROR.with(|error| { + let mut error = error.borrow_mut(); + error.clear(); + error.extend_from_slice(message.as_ref().as_bytes()); + }); +} + +fn write_row(frame: &mut Vec, row: &DynamicRow) -> Result<(), i32> { + write_length(frame, row.len())?; + for (name, value) in row { + write_string(frame, name)?; + match value { + CellValue::Empty => frame.push(0), + CellValue::Bool(value) => { + frame.push(1); + frame.push(u8::from(*value)); + } + CellValue::Int(value) => { + frame.push(2); + frame.extend_from_slice(&value.to_le_bytes()); + } + CellValue::Float(value) => { + frame.push(3); + frame.extend_from_slice(&value.to_le_bytes()); + } + CellValue::String(value) => { + frame.push(4); + write_string(frame, value)?; + } + CellValue::Date(value) => { + frame.push(5); + write_string(frame, value.format("%Y-%m-%d").to_string())?; + } + CellValue::Time(value) => { + frame.push(6); + write_string(frame, value.format("%H:%M:%S%.f").to_string())?; + } + CellValue::DateTime(value) => { + frame.push(7); + let value = if value.date() + == NaiveDate::from_ymd_opt(1899, 12, 31).expect("valid Excel epoch date") + { + *value - Duration::days(1) + } else { + *value + }; + write_string(frame, value.format("%Y-%m-%dT%H:%M:%S%.f").to_string())?; + } + CellValue::Duration(value) => { + frame.push(3); + let excel_days = value.num_milliseconds() as f64 / 86_400_000_f64; + frame.extend_from_slice(&excel_days.to_le_bytes()); + } + CellValue::Error(value) => { + frame.push(9); write_string(frame, value)?; } - CellValue::Date(value) => { - frame.push(5); - write_string(frame, value.format("%Y-%m-%d").to_string())?; + } + } + Ok(()) +} + +fn write_string(frame: &mut Vec, value: impl AsRef) -> Result<(), i32> { + let bytes = value.as_ref().as_bytes(); + write_length(frame, bytes.len())?; + frame.extend_from_slice(bytes); + Ok(()) +} + +fn write_strings(values: Vec) -> Result, i32> { + let mut frame = Vec::new(); + write_length(&mut frame, values.len())?; + for value in values { + write_string(&mut frame, value)?; + } + Ok(frame) +} + +fn write_optional_string(frame: &mut Vec, value: Option<&str>) -> Result<(), i32> { + frame.push(u8::from(value.is_some())); + if let Some(value) = value { + write_string(frame, value)?; + } + Ok(()) +} + +fn write_person(frame: &mut Vec, person: Option<&CommentPerson>) -> Result<(), i32> { + frame.push(u8::from(person.is_some())); + if let Some(person) = person { + write_string(frame, person.id().to_string())?; + write_string(frame, person.display_name())?; + write_optional_string(frame, person.provider_id())?; + } + Ok(()) +} + +fn write_timestamp(frame: &mut Vec, timestamp: Option<&CommentTimestamp>) -> Result<(), i32> { + let value = timestamp.map(|value| match value { + CommentTimestamp::Local(value) => value.format("%Y-%m-%dT%H:%M:%S%.f").to_string(), + CommentTimestamp::Offset(value) => value.to_rfc3339(), + }); + write_optional_string(frame, value.as_deref()) +} + +fn csv_read_options( + use_header_row: u8, + delimiter: u8, + encoding: u8, + read_empty_as_null: u8, + trim_headers: u8, +) -> Result { + if delimiter == 0 { + set_last_error("delimiter must be a single-byte character"); + return Err(ERROR_INVALID_ARGUMENT); + } + let encoding = parse_csv_encoding(encoding)?; + let configuration = CsvConfiguration::new() + .with_delimiter(delimiter) + .with_encoding(encoding) + .with_read_empty_as_null(read_empty_as_null != 0); + Ok(CsvReadOptions::new() + .with_configuration(configuration) + .with_header_mode(if use_header_row == 0 { + HeaderMode::None + } else { + HeaderMode::FirstRow + }) + .with_trim_headers(trim_headers != 0)) +} + +fn parse_csv_encoding(encoding: u8) -> Result { + match encoding { + 0 => Ok(CsvEncoding::Utf8), + 1 => Ok(CsvEncoding::Utf16Le), + 2 => Ok(CsvEncoding::Utf16Be), + 3 => Ok(CsvEncoding::Gbk), + 4 => Ok(CsvEncoding::Windows1252), + _ => { + set_last_error("encoding is not supported"); + Err(ERROR_INVALID_ARGUMENT) + } + } +} + +unsafe fn write_csv( + arguments: CsvWriteArguments, + append: bool, + out_row_count: *mut u32, +) -> Result { + let CsvWriteArguments { + path, + data, + data_length, + delimiter, + encoding, + write_bom, + print_header, + overwrite_file, + } = arguments; + if path.is_null() || data.is_null() || out_row_count.is_null() { + set_last_error("path, data, and out_row_count are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + if delimiter == 0 { + set_last_error("delimiter must be a single-byte character"); + return Err(ERROR_INVALID_ARGUMENT); + } + + unsafe { ptr::write(out_row_count, 0) }; + let path = unsafe { read_utf8(path) }?; + let rows = decode_rows(unsafe { std::slice::from_raw_parts(data, data_length) })?; + let configuration = CsvConfiguration::new() + .with_delimiter(delimiter) + .with_encoding(parse_csv_encoding(encoding)?) + .with_write_bom(write_bom != 0); + let options = CsvWriteOptions::new() + .with_configuration(configuration) + .with_print_header(print_header != 0) + .with_overwrite_file(overwrite_file != 0); + let count = if append { + MiniExcel::append_csv(path, &rows, &options) + } else { + MiniExcel::save_csv(path, &rows, &options) + } + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + let count = u32::try_from(count).map_err(|_| { + set_last_error("row count exceeds the ABI limit"); + ERROR_WRITE + })?; + unsafe { ptr::write(out_row_count, count) }; + Ok(RESULT_BATCH) +} + +fn decode_rows(bytes: &[u8]) -> Result, i32> { + let mut reader = FrameInput::new(bytes); + let rows = read_rows(&mut reader)?; + reader.ensure_complete()?; + Ok(rows) +} + +fn configured_schema(payload: &serde_json::Value) -> Result>, i32> { + let Some(schema) = payload.get("schema") else { + return Ok(None); + }; + let values = schema + .as_array() + .ok_or_else(|| invalid_write_options("schema must be an array"))?; + values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| invalid_write_options("schema values must be strings")) + }) + .collect::, _>>() + .map(Some) +} + +fn configured_formula_columns(payload: &serde_json::Value) -> Result, i32> { + let Some(columns) = payload.get("formulaColumns") else { + return Ok(Vec::new()); + }; + let values = columns + .as_array() + .ok_or_else(|| invalid_write_options("formulaColumns must be an array"))?; + values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| invalid_write_options("formulaColumns values must be strings")) + }) + .collect() +} + +fn write_configured_workbook( + path: impl AsRef, + rows: &[DynamicRow], + schema: Option<&[String]>, + options: &WriteOptions, +) -> Result<(), i32> { + match schema { + Some(schema) => MiniExcel::save_as_with_schema(path, schema, rows, options), + None => MiniExcel::save_as_with_options(path, rows, options), + } + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + }) +} + +fn write_error(error: impl std::fmt::Display) -> i32 { + set_last_error(format!("failed to write output: {error}")); + ERROR_WRITE +} + +#[cfg(windows)] +fn publish_staged_file(source: &Path, destination: &Path) -> Result<(), i32> { + atomicwrites::replace_atomic(source, destination).map_err(write_error) +} + +#[cfg(not(windows))] +fn publish_staged_file(source: &Path, destination: &Path) -> Result<(), i32> { + std::fs::rename(source, destination).map_err(write_error) +} + +fn configured_write_options(payload: &serde_json::Value) -> Result { + let mut options = WriteOptions::new() + .with_sheet_name(json_string(payload, "sheetName", "Sheet1")?) + .with_overwrite_file(json_bool(payload, "overwriteFile", false)?) + .with_print_header(json_bool(payload, "printHeader", true)?) + .with_auto_filter(json_bool(payload, "autoFilter", true)?) + .with_right_to_left(json_bool(payload, "rightToLeft", false)?) + .with_auto_width(json_bool(payload, "autoWidth", false)?) + .with_wrap_cell_contents(json_bool(payload, "wrapCellContents", false)?) + .with_min_width(json_f64(payload, "minWidth", 8.42857143)?) + .with_max_width(json_f64(payload, "maxWidth", 200.0)?) + .with_freeze_row_count( + json_u64(payload, "freezeRowCount", 1)? + .try_into() + .map_err(|_| invalid_write_options("freezeRowCount exceeds UInt32"))?, + ) + .with_freeze_column_count( + json_u64(payload, "freezeColumnCount", 0)? + .try_into() + .map_err(|_| invalid_write_options("freezeColumnCount exceeds UInt16"))?, + ) + .with_horizontal_alignment(parse_horizontal_alignment(json_string( + payload, + "horizontalAlignment", + "left", + )?)?) + .with_vertical_alignment(parse_vertical_alignment(json_string( + payload, + "verticalAlignment", + "bottom", + )?)?) + .with_table_style( + match json_string(payload, "tableStyle", "default")?.as_str() { + "none" => TableStyle::None, + "default" => TableStyle::Default, + _ => return Err(invalid_write_options("tableStyle must be none or default")), + }, + ); + let header_style = HeaderStyle::new() + .with_wrap_text(json_bool(payload, "headerWrapText", false)?) + .with_background_color(parse_rgb_color(&json_string( + payload, + "headerBackgroundColor", + "4472C4", + )?)?) + .with_horizontal_alignment(parse_horizontal_alignment(json_string( + payload, + "headerHorizontalAlignment", + "left", + )?)?) + .with_vertical_alignment(parse_vertical_alignment(json_string( + payload, + "headerVerticalAlignment", + "bottom", + )?)?); + options = options.with_header_style(header_style); + for (property, setter) in [ + ("dateFormat", 0_u8), + ("timeFormat", 1), + ("dateTimeFormat", 2), + ("durationFormat", 3), + ] { + if let Some(value) = payload.get(property).and_then(serde_json::Value::as_str) { + options = match setter { + 0 => options.with_date_format(value), + 1 => options.with_time_format(value), + 2 => options.with_datetime_format(value), + _ => options.with_duration_format(value), + }; + } + } + if let Some(values) = payload + .get("columnFormats") + .and_then(serde_json::Value::as_object) + { + for (name, value) in values { + options = options.with_column_format( + name, + value + .as_str() + .ok_or_else(|| invalid_write_options("columnFormats values must be strings"))?, + ); + } + } + if let Some(values) = payload + .get("columnWidths") + .and_then(serde_json::Value::as_object) + { + for (name, value) in values { + options = options.with_column_width( + name, + value + .as_f64() + .ok_or_else(|| invalid_write_options("columnWidths values must be numbers"))?, + ); + } + } + if let Some(values) = payload + .get("hiddenColumns") + .and_then(serde_json::Value::as_object) + { + for (name, value) in values { + options = options.with_column_hidden( + name, + value.as_bool().ok_or_else(|| { + invalid_write_options("hiddenColumns values must be booleans") + })?, + ); + } + } + Ok(options) +} + +fn parse_horizontal_alignment(value: String) -> Result { + match value.as_str() { + "left" => Ok(HorizontalAlignment::Left), + "center" => Ok(HorizontalAlignment::Center), + "right" => Ok(HorizontalAlignment::Right), + _ => Err(invalid_write_options( + "horizontal alignment must be left, center, or right", + )), + } +} + +fn parse_vertical_alignment(value: String) -> Result { + match value.as_str() { + "bottom" => Ok(VerticalAlignment::Bottom), + "center" => Ok(VerticalAlignment::Center), + "top" => Ok(VerticalAlignment::Top), + _ => Err(invalid_write_options( + "vertical alignment must be bottom, center, or top", + )), + } +} + +fn parse_rgb_color(value: &str) -> Result { + let value = value.strip_prefix('#').unwrap_or(value); + if value.len() != 6 { + return Err(invalid_write_options( + "headerBackgroundColor must be a six-digit RGB value", + )); + } + let color = u32::from_str_radix(value, 16) + .map_err(|_| invalid_write_options("headerBackgroundColor is not valid hexadecimal"))?; + Ok(RgbColor::new( + ((color >> 16) & 0xff) as u8, + ((color >> 8) & 0xff) as u8, + (color & 0xff) as u8, + )) +} + +fn json_string(payload: &serde_json::Value, name: &str, default: &str) -> Result { + match payload.get(name) { + None => Ok(default.to_owned()), + Some(value) => value + .as_str() + .map(str::to_owned) + .ok_or_else(|| invalid_write_options(&format!("{name} must be a string"))), + } +} + +fn json_bool(payload: &serde_json::Value, name: &str, default: bool) -> Result { + match payload.get(name) { + None => Ok(default), + Some(value) => value + .as_bool() + .ok_or_else(|| invalid_write_options(&format!("{name} must be a boolean"))), + } +} + +fn json_f64(payload: &serde_json::Value, name: &str, default: f64) -> Result { + match payload.get(name) { + None => Ok(default), + Some(value) => value + .as_f64() + .ok_or_else(|| invalid_write_options(&format!("{name} must be a number"))), + } +} + +fn json_u64(payload: &serde_json::Value, name: &str, default: u64) -> Result { + match payload.get(name) { + None => Ok(default), + Some(value) => value.as_u64().ok_or_else(|| { + invalid_write_options(&format!("{name} must be a non-negative integer")) + }), + } +} + +fn invalid_write_options(message: &str) -> i32 { + set_last_error(format!("invalid write options: {message}")); + ERROR_INVALID_ARGUMENT +} + +fn declared_sheet_dimensions(path: &str) -> Result, i32> { + let file = File::open(path).map_err(metadata_error)?; + let mut archive = ZipArchive::new(file).map_err(metadata_error)?; + let workbook = read_zip_entry(&mut archive, "xl/workbook.xml")?; + let relationships = read_zip_entry(&mut archive, "xl/_rels/workbook.xml.rels")?; + let sheet_relationship_ids = workbook_sheet_relationships(&workbook)?; + let relationship_targets = workbook_relationship_targets(&relationships)?; + let mut dimensions = Vec::with_capacity(sheet_relationship_ids.len()); + for relationship_id in sheet_relationship_ids { + let target = relationship_targets.get(&relationship_id).ok_or_else(|| { + set_last_error(format!( + "workbook relationship '{relationship_id}' was not found" + )); + ERROR_QUERY + })?; + let worksheet_path = normalize_workbook_target(target); + let worksheet = read_zip_entry(&mut archive, &worksheet_path)?; + dimensions.push(worksheet_declared_dimension(&worksheet)?); + } + Ok(dimensions) +} + +fn worksheet_physical_row_pattern( + path: &str, + sheet_name: Option<&str>, + start_row: usize, + end_row: Option, +) -> Result<(Vec, Vec), i32> { + let file = File::open(path).map_err(metadata_error)?; + let mut archive = ZipArchive::new(file).map_err(metadata_error)?; + let workbook = read_zip_entry(&mut archive, "xl/workbook.xml")?; + let relationships = read_zip_entry(&mut archive, "xl/_rels/workbook.xml.rels")?; + let sheets = workbook_sheets(&workbook)?; + let relationship_id = match sheet_name { + Some(name) => sheets + .iter() + .find(|(sheet, _)| sheet.eq_ignore_ascii_case(name)) + .map(|(_, relationship)| relationship), + None => sheets.first().map(|(_, relationship)| relationship), + } + .ok_or_else(|| { + set_last_error(format!( + "worksheet '{}' was not found", + sheet_name.unwrap_or("") + )); + ERROR_QUERY + })?; + let targets = workbook_relationship_targets(&relationships)?; + let target = targets.get(relationship_id).ok_or_else(|| { + set_last_error(format!( + "workbook relationship '{relationship_id}' was not found" + )); + ERROR_QUERY + })?; + let worksheet = read_zip_entry(&mut archive, &normalize_workbook_target(target))?; + physical_row_pattern(&worksheet, start_row, end_row) +} + +fn physical_row_pattern( + worksheet: &[u8], + start_row: usize, + end_row: Option, +) -> Result<(Vec, Vec), i32> { + let mut reader = XmlReader::from_reader(worksheet); + let mut pattern = Vec::new(); + let mut current_row: Option<(usize, Vec)> = None; + let mut last_row = 0_usize; + let mut skip_next_data_row = false; + let mut merged_ranges = Vec::new(); + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) if event.local_name().as_ref() == b"row" => { + let row = row_number(&reader, &event, last_row + 1)?; + last_row = row; + current_row = Some((row, Vec::new())); + } + Event::Empty(event) if event.local_name().as_ref() == b"row" => { + let row = row_number(&reader, &event, last_row + 1)?; + last_row = row; + if row >= start_row && end_row.is_none_or(|end| row <= end) { + pattern.push(PhysicalRowAction::Empty); + skip_next_data_row = true; + } } - CellValue::Time(value) => { - frame.push(6); - write_string(frame, value.format("%H:%M:%S%.f").to_string())?; + Event::Start(event) | Event::Empty(event) if event.local_name().as_ref() == b"c" => { + if let Some((_, columns)) = current_row.as_mut() { + let column = xml_attribute(&reader, &event, b"r")? + .as_deref() + .map(cell_column_index) + .transpose()? + .unwrap_or(columns.len() + 1); + columns.push(column); + } } - CellValue::DateTime(value) => { - frame.push(7); - write_string(frame, value.format("%Y-%m-%dT%H:%M:%S%.f").to_string())?; + Event::End(event) if event.local_name().as_ref() == b"row" => { + if let Some((row, columns)) = current_row.take() { + if !columns.is_empty() + && row >= start_row + && end_row.is_none_or(|end| row <= end) + { + if skip_next_data_row { + skip_next_data_row = false; + pattern.push(PhysicalRowAction::Skip); + } else { + pattern.push(PhysicalRowAction::Data { row, columns }); + } + } + } } - CellValue::Duration(value) => { - frame.push(8); - frame.extend_from_slice(&value.num_milliseconds().to_le_bytes()); + Event::Empty(event) if event.local_name().as_ref() == b"mergeCell" => { + if let Some(reference) = xml_attribute(&reader, &event, b"ref")? { + merged_ranges.push(parse_merged_range(&reference)?); + } } - CellValue::Error(value) => { - frame.push(9); - write_string(frame, value)?; + Event::Eof => break, + _ => {} + } + } + Ok((pattern, merged_ranges)) +} + +fn parse_merged_range(reference: &str) -> Result { + let (start, end) = reference.split_once(':').unwrap_or((reference, reference)); + Ok(MergedRangeInfo { + start_row: cell_row_index(start)?, + start_column: cell_column_index(start)?, + end_row: cell_row_index(end)?, + end_column: cell_column_index(end)?, + }) +} + +fn row_number( + reader: &XmlReader<&[u8]>, + event: &BytesStart<'_>, + fallback: usize, +) -> Result { + match xml_attribute(reader, event, b"r")? { + Some(value) => value.parse().map_err(metadata_error), + None => Ok(fallback), + } +} + +fn cell_row_index(reference: &str) -> Result { + let digits = reference.trim_start_matches(|character: char| character.is_ascii_alphabetic()); + digits.parse().map_err(|error| { + set_last_error(format!("invalid cell row in '{reference}': {error}")); + ERROR_INVALID_ARGUMENT + }) +} + +fn cell_column_index(reference: &str) -> Result { + let mut column = 0_usize; + for character in reference.chars().take_while(char::is_ascii_alphabetic) { + column = column + .checked_mul(26) + .and_then(|value| { + value.checked_add(character.to_ascii_uppercase() as usize - 'A' as usize + 1) + }) + .ok_or_else(|| { + set_last_error(format!( + "cell column in '{reference}' exceeds the supported range" + )); + ERROR_INVALID_ARGUMENT + })?; + } + if column == 0 { + set_last_error(format!("invalid cell column in '{reference}'")); + return Err(ERROR_INVALID_ARGUMENT); + } + Ok(column) +} + +fn read_zip_entry( + archive: &mut ZipArchive, + path: &str, +) -> Result, i32> { + let mut entry = archive.by_name(path).map_err(metadata_error)?; + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).map_err(metadata_error)?; + Ok(bytes) +} + +fn workbook_sheet_relationships(workbook: &[u8]) -> Result, i32> { + let mut reader = XmlReader::from_reader(workbook); + let mut relationships = Vec::new(); + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) | Event::Empty(event) + if event.local_name().as_ref() == b"sheet" => + { + if let Some(value) = xml_attribute(&reader, &event, b"r:id")? { + relationships.push(value); + } } + Event::Eof => break, + _ => {} } } - Ok(()) + Ok(relationships) } -fn write_string(frame: &mut Vec, value: impl AsRef) -> Result<(), i32> { - let bytes = value.as_ref().as_bytes(); - write_length(frame, bytes.len())?; - frame.extend_from_slice(bytes); - Ok(()) +fn workbook_sheets(workbook: &[u8]) -> Result, i32> { + let mut reader = XmlReader::from_reader(workbook); + let mut sheets = Vec::new(); + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) | Event::Empty(event) + if event.local_name().as_ref() == b"sheet" => + { + if let (Some(name), Some(relationship)) = ( + xml_attribute(&reader, &event, b"name")?, + xml_attribute(&reader, &event, b"r:id")?, + ) { + sheets.push((name, relationship)); + } + } + Event::Eof => break, + _ => {} + } + } + Ok(sheets) +} + +fn workbook_relationship_targets(relationships: &[u8]) -> Result, i32> { + let mut reader = XmlReader::from_reader(relationships); + let mut targets = HashMap::new(); + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) | Event::Empty(event) + if event.local_name().as_ref() == b"Relationship" => + { + if let (Some(id), Some(target)) = ( + xml_attribute(&reader, &event, b"Id")?, + xml_attribute(&reader, &event, b"Target")?, + ) { + targets.insert(id, target); + } + } + Event::Eof => break, + _ => {} + } + } + Ok(targets) +} + +fn worksheet_declared_dimension(worksheet: &[u8]) -> Result { + let mut reader = XmlReader::from_reader(worksheet); + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) | Event::Empty(event) + if event.local_name().as_ref() == b"dimension" => + { + let reference = xml_attribute(&reader, &event, b"ref")?; + return Ok(match reference { + Some(reference) => { + let (start, end) = reference + .split_once(':') + .map_or((reference.as_str(), reference.as_str()), |value| value); + (Some(start.to_owned()), Some(end.to_owned())) + } + None => (None, None), + }); + } + Event::Start(event) if event.local_name().as_ref() == b"sheetData" => { + return Ok((None, None)); + } + Event::Eof => return Ok((None, None)), + _ => {} + } + } +} + +fn xml_attribute( + reader: &XmlReader<&[u8]>, + event: &BytesStart<'_>, + name: &[u8], +) -> Result, i32> { + for attribute in event.attributes() { + let attribute = attribute.map_err(metadata_error)?; + if attribute.key.as_ref() == name { + return attribute + .decode_and_unescape_value(reader.decoder()) + .map(|value| Some(value.into_owned())) + .map_err(metadata_error); + } + } + Ok(None) +} + +fn normalize_workbook_target(target: &str) -> String { + let target = target.trim_start_matches('/'); + if target.starts_with("xl/") { + target.to_owned() + } else { + format!("xl/{target}") + } +} + +fn metadata_error(error: impl std::fmt::Display) -> i32 { + set_last_error(format!("failed to read XLSX metadata: {error}")); + ERROR_QUERY +} + +#[allow(clippy::too_many_arguments)] +fn add_png_picture( + path: &str, + sheet_name: Option<&str>, + cell_address: &str, + image: &[u8], + width_px: u32, + height_px: u32, + anchor_type: u8, + location_x: i32, + location_y: i32, +) -> Result<(), i32> { + const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n"; + if !image.starts_with(PNG_SIGNATURE) { + set_last_error("only PNG picture data is currently supported"); + return Err(ERROR_INVALID_ARGUMENT); + } + if anchor_type > 2 { + set_last_error("anchor_type must be 0 (one-cell), 1 (absolute), or 2 (two-cell)"); + return Err(ERROR_INVALID_ARGUMENT); + } + let column = cell_column_index(cell_address)? - 1; + let row = cell_row_index(cell_address)? - 1; + let file = File::open(path).map_err(write_error)?; + let mut archive = ZipArchive::new(file).map_err(write_error)?; + let names = archive.file_names().map(str::to_owned).collect::>(); + let workbook = read_zip_entry(&mut archive, "xl/workbook.xml")?; + let workbook_rels = read_zip_entry(&mut archive, "xl/_rels/workbook.xml.rels")?; + let sheets = workbook_sheets(&workbook)?; + let relationship_id = match sheet_name { + Some(name) => sheets + .iter() + .find(|(sheet, _)| sheet.eq_ignore_ascii_case(name)) + .map(|(_, relationship)| relationship), + None => sheets.first().map(|(_, relationship)| relationship), + } + .ok_or_else(|| { + set_last_error(format!( + "worksheet '{}' was not found", + sheet_name.unwrap_or("") + )); + ERROR_QUERY + })?; + let targets = workbook_relationship_targets(&workbook_rels)?; + let worksheet_path = + normalize_workbook_target(targets.get(relationship_id).ok_or_else(|| { + set_last_error(format!( + "workbook relationship '{relationship_id}' was not found" + )); + ERROR_QUERY + })?); + let worksheet_name = worksheet_path.rsplit('/').next().ok_or_else(|| { + set_last_error("worksheet path has no file name"); + ERROR_QUERY + })?; + let worksheet_rels_path = format!("xl/worksheets/_rels/{worksheet_name}.rels"); + let mut worksheet_xml = String::from_utf8(read_zip_entry(&mut archive, &worksheet_path)?) + .map_err(metadata_error)?; + let mut worksheet_rels = read_optional_zip_entry(&mut archive, &worksheet_rels_path)? + .map(String::from_utf8) + .transpose() + .map_err(metadata_error)? + .unwrap_or_else(empty_relationships_xml); + + let existing_drawing_id = drawing_relationship_id(worksheet_xml.as_bytes())?; + let (drawing_path, drawing_rel_id) = if let Some(id) = existing_drawing_id { + let targets = workbook_relationship_targets(worksheet_rels.as_bytes())?; + let target = targets.get(&id).ok_or_else(|| { + set_last_error(format!( + "worksheet drawing relationship '{id}' was not found" + )); + ERROR_QUERY + })?; + (normalize_part_target(&worksheet_path, target), id) + } else { + let index = next_numbered_part(&names, "xl/drawings/drawing", ".xml"); + let drawing_path = format!("xl/drawings/drawing{index}.xml"); + let relationship_id = next_relationship_id(worksheet_rels.as_bytes())?; + worksheet_xml = ensure_relationship_namespace(&worksheet_xml); + worksheet_xml = insert_before( + &worksheet_xml, + "", + &format!(""), + )?; + worksheet_rels = append_relationship( + &worksheet_rels, + &relationship_id, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing", + &format!("../drawings/drawing{index}.xml"), + )?; + (drawing_path, relationship_id) + }; + let _ = drawing_rel_id; + + let drawing_name = drawing_path.rsplit('/').next().expect("drawing file name"); + let drawing_rels_path = format!("xl/drawings/_rels/{drawing_name}.rels"); + let mut drawing_xml = read_optional_zip_entry(&mut archive, &drawing_path)? + .map(String::from_utf8) + .transpose() + .map_err(metadata_error)? + .unwrap_or_else(empty_drawing_xml); + let mut drawing_rels = read_optional_zip_entry(&mut archive, &drawing_rels_path)? + .map(String::from_utf8) + .transpose() + .map_err(metadata_error)? + .unwrap_or_else(empty_relationships_xml); + let image_index = next_numbered_part(&names, "xl/media/image", ".png"); + let image_path = format!("xl/media/image{image_index}.png"); + let image_rel_id = next_relationship_id(drawing_rels.as_bytes())?; + drawing_rels = append_relationship( + &drawing_rels, + &image_rel_id, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + &format!("../media/image{image_index}.png"), + )?; + let picture_id = drawing_anchor_count(drawing_xml.as_bytes())? + 2; + let anchor = picture_anchor_xml( + column, + row, + width_px, + height_px, + &image_rel_id, + picture_id, + anchor_type, + location_x, + location_y, + ); + drawing_xml = insert_before(&drawing_xml, "", &anchor)?; + + let mut content_types = String::from_utf8(read_zip_entry(&mut archive, "[Content_Types].xml")?) + .map_err(metadata_error)?; + if !content_types.contains("ContentType=\"image/png\"") { + content_types = insert_before( + &content_types, + "", + "", + )?; + } + let drawing_part = format!("/{drawing_path}"); + if !content_types.contains(&drawing_part) { + content_types = insert_before( + &content_types, + "", + &format!( + "" + ), + )?; + } + + let mut replacements = BTreeMap::new(); + replacements.insert(worksheet_path, worksheet_xml.into_bytes()); + replacements.insert(worksheet_rels_path, worksheet_rels.into_bytes()); + replacements.insert(drawing_path, drawing_xml.into_bytes()); + replacements.insert(drawing_rels_path, drawing_rels.into_bytes()); + replacements.insert("[Content_Types].xml".to_owned(), content_types.into_bytes()); + replacements.insert(image_path, image.to_vec()); + rewrite_package(path, archive, replacements) +} + +fn read_optional_zip_entry( + archive: &mut ZipArchive, + path: &str, +) -> Result>, i32> { + match archive.by_name(path) { + Ok(mut entry) => { + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).map_err(write_error)?; + Ok(Some(bytes)) + } + Err(zip::result::ZipError::FileNotFound) => Ok(None), + Err(error) => Err(write_error(error)), + } +} + +fn rewrite_package( + path: &str, + archive: ZipArchive, + replacements: BTreeMap>, +) -> Result<(), i32> { + write_rewritten_package(path, archive, replacements, true) +} + +fn write_rewritten_package( + path: &str, + mut archive: ZipArchive, + replacements: BTreeMap>, + overwrite: bool, +) -> Result<(), i32> { + let destination = Path::new(path); + if destination.exists() && !overwrite { + return Err(write_error("the destination file already exists")); + } + let parent = destination.parent().unwrap_or_else(|| Path::new(".")); + let mut temporary = tempfile::Builder::new() + .prefix(".miniexcel-package-") + .suffix(".xlsx") + .tempfile_in(parent) + .map_err(write_error)?; + { + let mut writer = ZipWriter::new(temporary.as_file_mut()); + let mut written = std::collections::HashSet::new(); + for index in 0..archive.len() { + let entry = archive.by_index_raw(index).map_err(write_error)?; + let name = entry.name().to_owned(); + if let Some(replacement) = replacements.get(&name) { + writer + .start_file(&name, entry.options()) + .map_err(write_error)?; + writer.write_all(replacement).map_err(write_error)?; + } else { + writer.raw_copy_file(entry).map_err(write_error)?; + } + written.insert(name); + } + for (name, bytes) in &replacements { + if !written.contains(name) { + writer + .start_file( + name, + SimpleFileOptions::default() + .compression_method(CompressionMethod::Deflated), + ) + .map_err(write_error)?; + writer.write_all(bytes).map_err(write_error)?; + } + } + writer.finish().map_err(write_error)?; + } + drop(archive); + temporary.as_file().sync_all().map_err(write_error)?; + if overwrite { + let staging = temporary.into_temp_path(); + publish_staged_file(staging.as_ref(), destination) + } else { + temporary + .persist(destination) + .map_err(|error| write_error(error.error))?; + Ok(()) + } +} + +fn fill_mapped_template( + destination_path: &str, + template_path: &str, + payload: &serde_json::Value, + overwrite: bool, +) -> Result<(), i32> { + let sheet_name = payload + .get("sheetName") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| invalid_write_options("sheetName is required"))?; + let cells = payload + .get("cells") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| invalid_write_options("cells must be an array"))?; + let file = File::open(template_path).map_err(write_error)?; + let mut archive = ZipArchive::new(file).map_err(write_error)?; + let workbook = read_zip_entry(&mut archive, "xl/workbook.xml")?; + let workbook_rels = read_zip_entry(&mut archive, "xl/_rels/workbook.xml.rels")?; + let relationship_id = workbook_sheets(&workbook)? + .into_iter() + .find(|(name, _)| name.eq_ignore_ascii_case(sheet_name)) + .map(|(_, relationship)| relationship) + .ok_or_else(|| { + set_last_error(format!("worksheet '{sheet_name}' was not found")); + ERROR_QUERY + })?; + let targets = workbook_relationship_targets(&workbook_rels)?; + let worksheet_path = + normalize_workbook_target(targets.get(&relationship_id).ok_or_else(|| { + set_last_error(format!( + "workbook relationship '{relationship_id}' was not found" + )); + ERROR_QUERY + })?); + let mut worksheet = + String::from_utf8(read_zip_entry(&mut archive, &worksheet_path)?).map_err(write_error)?; + let mut ordered_cells = cells.iter().collect::>(); + ordered_cells.sort_by_key(|cell| { + let address = cell + .get("address") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + ( + cell_row_index(address).unwrap_or(usize::MAX), + cell_column_index(address).unwrap_or(usize::MAX), + ) + }); + for cell in ordered_cells { + let address = cell + .get("address") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| invalid_write_options("each mapped cell requires an address"))?; + let row = cell_row_index(address)?; + let formula = cell + .get("formula") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let value = cell.get("value").unwrap_or(&serde_json::Value::Null); + worksheet = upsert_worksheet_cell(&worksheet, address, row, value, formula)?; + } + let mut replacements = BTreeMap::new(); + replacements.insert(worksheet_path, worksheet.into_bytes()); + write_rewritten_package(destination_path, archive, replacements, overwrite) +} + +fn upsert_worksheet_cell( + worksheet: &str, + address: &str, + row: usize, + value: &serde_json::Value, + formula: bool, +) -> Result { + if let Some((start, end, style)) = find_cell_element(worksheet, address) { + let cell = mapped_cell_xml(address, value, formula, style.as_deref()); + return Ok(format!( + "{}{}{}", + &worksheet[..start], + cell, + &worksheet[end..] + )); + } + let cell = mapped_cell_xml(address, value, formula, None); + if let Some((start, tag_end, end, empty)) = find_row_element(worksheet, row) { + if empty { + let start_tag = worksheet[start..tag_end - 1].trim_end_matches('/'); + let replacement = format!("{start_tag}>{cell}"); + return Ok(format!( + "{}{}{}", + &worksheet[..start], + replacement, + &worksheet[end..] + )); + } + let insert_at = worksheet[start..end] + .rfind("") + .map(|index| start + index) + .ok_or_else(|| write_error(format!("row {row} has no closing tag")))?; + return Ok(format!( + "{}{}{}", + &worksheet[..insert_at], + cell, + &worksheet[insert_at..] + )); + } + let new_row = format!("{cell}"); + if let Some(insert_at) = worksheet.find("") { + return Ok(format!( + "{}{}{}", + &worksheet[..insert_at], + new_row, + &worksheet[insert_at..] + )); + } + if let Some(start) = worksheet.find("') + .ok_or_else(|| write_error("invalid sheetData element"))? + + 1; + if worksheet[start..tag_end].trim_end().ends_with("/>") { + return Ok(format!( + "{}{new_row}{}", + &worksheet[..start], + &worksheet[tag_end..] + )); + } + } + let insert_at = worksheet + .find("") + .ok_or_else(|| write_error("worksheet has no closing element"))?; + let sheet_data = format!("{new_row}"); + Ok(format!( + "{}{}{}", + &worksheet[..insert_at], + sheet_data, + &worksheet[insert_at..] + )) +} + +fn find_cell_element(worksheet: &str, address: &str) -> Option<(usize, usize, Option)> { + let attribute = format!("r=\"{address}\""); + let mut offset = 0; + while let Some(relative) = worksheet[offset..].find("' && boundary != b'/' { + offset = start + 2; + continue; + } + let tag_end = start + worksheet[start..].find('>')? + 1; + let tag = &worksheet[start..tag_end]; + if !tag.contains(&attribute) { + offset = tag_end; + continue; + } + let style = xml_tag_attribute(tag, "s"); + let end = if tag.trim_end().ends_with("/>") { + tag_end + } else { + tag_end + worksheet[tag_end..].find("")? + 4 + }; + return Some((start, end, style)); + } + None +} + +fn find_row_element(worksheet: &str, row: usize) -> Option<(usize, usize, usize, bool)> { + let attribute = format!("r=\"{row}\""); + let mut offset = 0; + while let Some(relative) = worksheet[offset..].find("')? + 1; + let tag = &worksheet[start..tag_end]; + if !tag.contains(&attribute) { + offset = tag_end; + continue; + } + let empty = tag.trim_end().ends_with("/>"); + let end = if empty { + tag_end + } else { + tag_end + worksheet[tag_end..].find("")? + 6 + }; + return Some((start, tag_end, end, empty)); + } + None +} + +fn xml_tag_attribute(tag: &str, name: &str) -> Option { + let prefix = format!("{name}=\""); + let start = tag.find(&prefix)? + prefix.len(); + let end = start + tag[start..].find('"')?; + Some(tag[start..end].to_owned()) +} + +fn mapped_cell_xml( + address: &str, + value: &serde_json::Value, + formula: bool, + style: Option<&str>, +) -> String { + let style = style.map_or_else(String::new, |value| format!(" s=\"{value}\"")); + if formula { + let formula = value.as_str().unwrap_or_default().trim_start_matches('='); + return format!( + "{}", + xml_escape(formula) + ); + } + match value { + serde_json::Value::Null => format!(""), + serde_json::Value::Bool(value) => format!( + "{}", + u8::from(*value) + ), + serde_json::Value::Number(value) => { + format!("{value}") + } + value => { + let text = value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()); + format!( + "{}", + xml_escape(&text) + ) + } + } +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn drawing_relationship_id(worksheet: &[u8]) -> Result, i32> { + let mut reader = XmlReader::from_reader(worksheet); + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) | Event::Empty(event) + if event.local_name().as_ref() == b"drawing" => + { + return xml_attribute(&reader, &event, b"r:id"); + } + Event::Eof => return Ok(None), + _ => {} + } + } +} + +fn drawing_anchor_count(drawing: &[u8]) -> Result { + let mut reader = XmlReader::from_reader(drawing); + let mut count = 0; + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) + if matches!( + event.local_name().as_ref(), + b"oneCellAnchor" | b"twoCellAnchor" | b"absoluteAnchor" + ) => + { + count += 1 + } + Event::Eof => return Ok(count), + _ => {} + } + } +} + +fn next_relationship_id(relationships: &[u8]) -> Result { + let targets = workbook_relationship_targets(relationships)?; + let mut index = 1; + loop { + let candidate = format!("rId{index}"); + if !targets.contains_key(&candidate) { + return Ok(candidate); + } + index += 1; + } +} + +fn next_numbered_part(names: &[String], prefix: &str, suffix: &str) -> usize { + let mut index = 1; + loop { + let candidate = format!("{prefix}{index}{suffix}"); + if !names.iter().any(|name| name == &candidate) { + return index; + } + index += 1; + } +} + +fn append_relationship( + xml: &str, + id: &str, + relationship_type: &str, + target: &str, +) -> Result { + insert_before( + xml, + "", + &format!(""), + ) +} + +fn insert_before(xml: &str, closing: &str, value: &str) -> Result { + let index = xml.rfind(closing).ok_or_else(|| { + set_last_error(format!("XML closing element '{closing}' was not found")); + ERROR_WRITE + })?; + let mut result = String::with_capacity(xml.len() + value.len()); + result.push_str(&xml[..index]); + result.push_str(value); + result.push_str(&xml[index..]); + Ok(result) +} + +fn ensure_relationship_namespace(xml: &str) -> String { + if xml.contains("xmlns:r=") { + return xml.to_owned(); + } + xml.replacen( + " String { + let mut parts = source_part + .rsplit_once('/') + .map_or(Vec::new(), |(parent, _)| { + parent.split('/').collect::>() + }); + for segment in target.split('/') { + match segment { + "" | "." => {} + ".." => { + parts.pop(); + } + value => parts.push(value), + } + } + parts.join("/") +} + +fn empty_relationships_xml() -> String { + "".to_owned() +} + +fn empty_drawing_xml() -> String { + "".to_owned() +} + +#[allow(clippy::too_many_arguments)] +fn picture_anchor_xml( + column: usize, + row: usize, + width_px: u32, + height_px: u32, + relationship_id: &str, + picture_id: usize, + anchor_type: u8, + location_x: i32, + location_y: i32, +) -> String { + let extent = format!( + "", + u64::from(width_px) * 9525, + u64::from(height_px) * 9525 + ); + let position = match anchor_type { + 1 => format!( + "{extent}", + i64::from(location_x) * 9525, + i64::from(location_y) * 9525 + ), + 2 => format!( + "{column}0{row}0{}0{}0", + column + 1, + row + 1 + ), + _ => format!( + "{column}0{row}0{extent}" + ), + }; + let anchor = match anchor_type { + 1 => "absoluteAnchor", + 2 => "twoCellAnchor", + _ => "oneCellAnchor", + }; + let edit_as = if anchor_type == 2 { + " editAs=\"twoCell\"" + } else { + "" + }; + format!( + "{position}" + ) +} + +fn decode_sheets(bytes: &[u8]) -> Result)>, i32> { + let mut reader = FrameInput::new(bytes); + let sheet_count = reader.read_length()?; + let mut sheets = Vec::with_capacity(sheet_count); + for _ in 0..sheet_count { + sheets.push((reader.read_string()?, read_rows(&mut reader)?)); + } + reader.ensure_complete()?; + Ok(sheets) +} + +fn read_rows(reader: &mut FrameInput<'_>) -> Result, i32> { + let row_count = reader.read_length()?; + let mut rows = Vec::with_capacity(row_count); + for _ in 0..row_count { + let cell_count = reader.read_length()?; + let mut row = DynamicRow::with_capacity(cell_count); + for _ in 0..cell_count { + let name = reader.read_string()?; + let value = match reader.read_byte()? { + 0 => CellValue::Empty, + 1 => CellValue::Bool(reader.read_byte()? != 0), + 2 => CellValue::Int(reader.read_i64()?), + 3 => CellValue::Float(f64::from_bits(reader.read_u64()?)), + 4 => CellValue::String(reader.read_string()?), + 5 => CellValue::Date( + NaiveDate::parse_from_str(&reader.read_string()?, "%Y-%m-%d") + .map_err(invalid_frame_value)?, + ), + 6 => CellValue::Time( + NaiveTime::parse_from_str(&reader.read_string()?, "%H:%M:%S%.f") + .map_err(invalid_frame_value)?, + ), + 7 => CellValue::DateTime( + NaiveDateTime::parse_from_str(&reader.read_string()?, "%Y-%m-%dT%H:%M:%S%.f") + .map_err(invalid_frame_value)?, + ), + 8 => CellValue::Duration(Duration::milliseconds(reader.read_i64()?)), + 9 => CellValue::Error(reader.read_string()?), + tag => { + set_last_error(format!("input frame contains unsupported value tag {tag}")); + return Err(ERROR_INVALID_ARGUMENT); + } + }; + row.insert(name, value); + } + rows.push(row); + } + Ok(rows) +} + +fn invalid_frame_value(error: chrono::ParseError) -> i32 { + set_last_error(format!( + "input frame contains an invalid temporal value: {error}" + )); + ERROR_INVALID_ARGUMENT +} + +fn insert_options( + sheet_name: &str, + print_header: u8, + replace_existing: u8, + remove_supported_relationships: u8, + overwrite_file: bool, +) -> InsertOptions { + InsertOptions::new() + .with_sheet_name(sheet_name) + .with_print_header(print_header != 0) + .with_existing_sheet_policy(if replace_existing == 0 { + ExistingSheetPolicy::Reject + } else { + ExistingSheetPolicy::Replace + }) + .with_target_relationship_policy(if remove_supported_relationships == 0 { + TargetRelationshipPolicy::Reject + } else { + TargetRelationshipPolicy::RemoveSupported + }) + .with_overwrite_file(overwrite_file) +} + +fn write_row_count(count: usize, out_row_count: *mut u32) -> Result { + let count = u32::try_from(count).map_err(|_| { + set_last_error("row count exceeds the ABI limit"); + ERROR_WRITE + })?; + unsafe { ptr::write(out_row_count, count) }; + Ok(RESULT_BATCH) +} + +struct FrameInput<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> FrameInput<'a> { + const fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn read_byte(&mut self) -> Result { + self.ensure_available(1)?; + let value = self.bytes[self.offset]; + self.offset += 1; + Ok(value) + } + + fn read_u32(&mut self) -> Result { + self.ensure_available(4)?; + let mut value = [0_u8; 4]; + value.copy_from_slice(&self.bytes[self.offset..self.offset + 4]); + self.offset += 4; + Ok(u32::from_le_bytes(value)) + } + + fn read_u64(&mut self) -> Result { + self.ensure_available(8)?; + let mut value = [0_u8; 8]; + value.copy_from_slice(&self.bytes[self.offset..self.offset + 8]); + self.offset += 8; + Ok(u64::from_le_bytes(value)) + } + + fn read_i64(&mut self) -> Result { + self.read_u64() + .map(|value| i64::from_le_bytes(value.to_le_bytes())) + } + + fn read_length(&mut self) -> Result { + self.read_u32().map(|value| value as usize) + } + + fn read_string(&mut self) -> Result { + let length = self.read_length()?; + self.ensure_available(length)?; + let value = std::str::from_utf8(&self.bytes[self.offset..self.offset + length]) + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_INVALID_ARGUMENT + })? + .to_owned(); + self.offset += length; + Ok(value) + } + + fn ensure_complete(&self) -> Result<(), i32> { + if self.offset == self.bytes.len() { + Ok(()) + } else { + set_last_error("input frame contains trailing data"); + Err(ERROR_INVALID_ARGUMENT) + } + } + + fn ensure_available(&self, length: usize) -> Result<(), i32> { + if self.offset <= self.bytes.len().saturating_sub(length) { + Ok(()) + } else { + set_last_error("input frame is truncated"); + Err(ERROR_INVALID_ARGUMENT) + } + } } fn write_length(frame: &mut Vec, length: usize) -> Result<(), i32> { @@ -279,4 +3527,26 @@ mod tests { let message = unsafe { std::slice::from_raw_parts(error, length) }; assert_eq!(message, b"path, start_cell, and out_handle are required"); } + + #[test] + fn rejects_missing_required_sheet_name_arguments() { + let result = unsafe { + miniexcel_get_sheet_names( + ptr::null(), + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ) + }; + + assert_eq!(result, ERROR_INVALID_ARGUMENT); + + let mut length = 0; + let error = unsafe { miniexcel_last_error(&mut length) }; + let message = unsafe { std::slice::from_raw_parts(error, length) }; + assert_eq!( + message, + b"path, out_handle, out_data, and out_length are required" + ); + } } diff --git a/src/MiniExcelRust/CompatibilityConfiguration.cs b/src/MiniExcelRust/CompatibilityConfiguration.cs new file mode 100644 index 0000000..43443a2 --- /dev/null +++ b/src/MiniExcelRust/CompatibilityConfiguration.cs @@ -0,0 +1,58 @@ +using System.Globalization; + +namespace MiniExcelLibs +{ + public interface IConfiguration + { + CultureInfo Culture { get; set; } + } + + public enum ExcelType + { + XLSX, + CSV, + UNKNOWN + } +} + +namespace MiniExcelLibs.OpenXml +{ + public sealed class OpenXmlConfiguration : IConfiguration + { + public CultureInfo Culture { get; set; } = CultureInfo.InvariantCulture; + public bool FillMergedCells { get; set; } + public bool TrimColumnNames { get; set; } = true; + public bool IgnoreEmptyRows { get; set; } + public bool EnableSharedStringCache { get; set; } = true; + public ulong SharedStringCacheSize { get; set; } = 5 * 1024 * 1024; + public string? SharedStringCachePath { get; set; } = Path.GetTempPath(); + + internal MiniExcelRustReadOptions ToReadOptions() => new() + { + Culture = Culture, + FillMergedCells = FillMergedCells, + TrimColumnNames = TrimColumnNames, + IgnoreEmptyRows = IgnoreEmptyRows, + EnableSharedStringCache = EnableSharedStringCache, + SharedStringCacheSize = SharedStringCacheSize, + SharedStringCachePath = SharedStringCachePath + }; + } +} + +namespace MiniExcelLibs.Csv +{ + public sealed class CsvConfiguration : IConfiguration + { + public CultureInfo Culture { get; set; } = CultureInfo.InvariantCulture; + public char Seperator { get; set; } = ','; + public bool ReadEmptyStringAsNull { get; set; } + + internal MiniExcelRustCsvReadOptions ToReadOptions() => new() + { + Culture = Culture, + Delimiter = Seperator, + ReadEmptyStringAsNull = ReadEmptyStringAsNull + }; + } +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcel.cs b/src/MiniExcelRust/MiniExcel.cs new file mode 100644 index 0000000..7858298 --- /dev/null +++ b/src/MiniExcelRust/MiniExcel.cs @@ -0,0 +1,869 @@ +using System.Data; +using System.Collections; +using System.Runtime.CompilerServices; + +namespace MiniExcelLibs; + +/// +/// Compatibility facade whose workbook operations are backed exclusively by MiniExcel Rust. +/// +public static class MiniExcel +{ + public static void AddPicture(string path, params MiniExcelRustPicture[] pictures) => + MiniExcelRust.AddPicture(path, pictures); + + public static void AddPicture( + Stream stream, + bool leaveOpen = false, + params MiniExcelRustPicture[] pictures) => + MiniExcelRust.AddPicture(stream, leaveOpen, pictures); + + public static Task AddPictureAsync( + string path, + CancellationToken cancellationToken = default, + params MiniExcelRustPicture[] pictures) => + Task.Run(() => MiniExcelRust.AddPicture(path, pictures), cancellationToken); + + public static Task AddPictureAsync( + Stream stream, + CancellationToken cancellationToken = default, + params MiniExcelRustPicture[] pictures) => + Task.Run(() => MiniExcelRust.AddPicture(stream, leaveOpen: true, pictures), cancellationToken); + + public static IEnumerable> Query( + string path, + bool useHeaderRow = false, + string? sheetName = null, + ExcelType excelType = ExcelType.UNKNOWN, + string startCell = "A1", + IConfiguration? configuration = null) => + IsCsv(path, excelType) + ? MiniExcelRust.QueryCsv(path, useHeaderRow, CsvOptions(configuration)) + : MiniExcelRust.Query(path, useHeaderRow, sheetName, startCell, OpenXmlOptions(configuration)); + + public static IEnumerable Query( + string path, + string? sheetName = null, + ExcelType excelType = ExcelType.UNKNOWN, + string startCell = "A1", + IConfiguration? configuration = null, + bool hasHeader = true) + where T : class, new() => + IsCsv(path, excelType) + ? MiniExcelRust.QueryCsv(path, !hasHeader, CsvOptions(configuration)) + : MiniExcelRust.Query(path, sheetName, startCell, !hasHeader, OpenXmlOptions(configuration)); + + public static IEnumerable> Query( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + ExcelType excelType = ExcelType.UNKNOWN, + string startCell = "A1", + IConfiguration? configuration = null) => + excelType == ExcelType.CSV + ? MiniExcelRust.QueryCsv(stream, useHeaderRow, CsvOptions(configuration), leaveOpen: true) + : MiniExcelRust.Query(stream, useHeaderRow, sheetName, startCell, OpenXmlOptions(configuration), leaveOpen: true); + + public static IEnumerable Query( + Stream stream, + string? sheetName = null, + ExcelType excelType = ExcelType.UNKNOWN, + string startCell = "A1", + IConfiguration? configuration = null, + bool hasHeader = true) + where T : class, new() => + excelType == ExcelType.CSV + ? MiniExcelRust.QueryCsv(stream, !hasHeader, CsvOptions(configuration), leaveOpen: true) + : MiniExcelRust.Query( + stream, + sheetName, + startCell, + !hasHeader, + OpenXmlOptions(configuration), + leaveOpen: true); + + public static IAsyncEnumerable> QueryAsync( + string path, + bool useHeaderRow = false, + string? sheetName = null, + ExcelType excelType = ExcelType.UNKNOWN, + string startCell = "A1", + IConfiguration? configuration = null, + CancellationToken cancellationToken = default) => + IsCsv(path, excelType) + ? MiniExcelRust.QueryCsvAsync(path, useHeaderRow, CsvOptions(configuration), cancellationToken) + : MiniExcelRust.QueryAsync(path, useHeaderRow, sheetName, startCell, OpenXmlOptions(configuration), cancellationToken); + + public static IAsyncEnumerable QueryAsync( + string path, + string? sheetName = null, + ExcelType excelType = ExcelType.UNKNOWN, + string startCell = "A1", + IConfiguration? configuration = null, + bool hasHeader = true, + CancellationToken cancellationToken = default) + where T : class, new() => + excelType == ExcelType.CSV + ? MiniExcelRust.QueryCsvAsync(path, !hasHeader, CsvOptions(configuration), cancellationToken) + : MiniExcelRust.QueryAsync(path, sheetName, startCell, !hasHeader, OpenXmlOptions(configuration), cancellationToken); + + public static IAsyncEnumerable> QueryAsync( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + ExcelType excelType = ExcelType.UNKNOWN, + string startCell = "A1", + IConfiguration? configuration = null, + CancellationToken cancellationToken = default) => + excelType == ExcelType.CSV + ? ToFacadeAsync(MiniExcelRust.QueryCsv(stream, useHeaderRow, CsvOptions(configuration), leaveOpen: true), cancellationToken) + : MiniExcelRust.QueryAsync( + stream, + useHeaderRow, + sheetName, + startCell, + OpenXmlOptions(configuration), + leaveOpen: true, + cancellationToken: cancellationToken); + + public static IAsyncEnumerable QueryAsync( + Stream stream, + string? sheetName = null, + ExcelType excelType = ExcelType.UNKNOWN, + string startCell = "A1", + IConfiguration? configuration = null, + bool hasHeader = true, + CancellationToken cancellationToken = default) + where T : class, new() => + excelType == ExcelType.CSV + ? ToFacadeAsync(MiniExcelRust.QueryCsv(stream, !hasHeader, CsvOptions(configuration), leaveOpen: true), cancellationToken) + : MiniExcelRust.QueryAsync( + stream, + sheetName, + startCell, + !hasHeader, + OpenXmlOptions(configuration), + leaveOpen: true, + cancellationToken: cancellationToken); + + public static IEnumerable> QueryRange( + string path, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + IConfiguration? configuration = null) => + MiniExcelRust.QueryRange(path, useHeaderRow, sheetName, startCell, endCell, OpenXmlOptions(configuration)); + + public static IAsyncEnumerable> QueryRangeAsync( + string path, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + CancellationToken cancellationToken = default) => + MiniExcelRust.QueryRangeAsync(path, useHeaderRow, sheetName, startCell, endCell, cancellationToken: cancellationToken); + + public static IEnumerable> QueryRange( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + IConfiguration? configuration = null, + bool leaveOpen = true) => + MiniExcelRust.QueryRange( + stream, + useHeaderRow, + sheetName, + startCell, + endCell, + OpenXmlOptions(configuration), + leaveOpen); + + public static IAsyncEnumerable> QueryRangeAsync( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + IConfiguration? configuration = null, + bool leaveOpen = true, + CancellationToken cancellationToken = default) => + MiniExcelRust.QueryRangeAsync( + stream, + useHeaderRow, + sheetName, + startCell, + endCell, + OpenXmlOptions(configuration), + leaveOpen, + cancellationToken); + + public static IAsyncEnumerable> QueryRangeAsync( + Stream stream, + bool useHeaderRow, + string? sheetName, + int startRowIndex, + int startColumnIndex, + int? endRowIndex = null, + int? endColumnIndex = null, + bool leaveOpen = true, + CancellationToken cancellationToken = default) => + MiniExcelRust.QueryRangeAsync( + stream, + useHeaderRow, + sheetName, + startRowIndex, + startColumnIndex, + endRowIndex, + endColumnIndex, + leaveOpen: leaveOpen, + cancellationToken: cancellationToken); + + public static IEnumerable> QueryRange( + string path, + bool useHeaderRow, + string? sheetName, + int startRowIndex, + int startColumnIndex, + int? endRowIndex = null, + int? endColumnIndex = null, + IConfiguration? configuration = null) => + MiniExcelRust.QueryRange( + path, + useHeaderRow, + sheetName, + startRowIndex, + startColumnIndex, + endRowIndex, + endColumnIndex, + OpenXmlOptions(configuration)); + + public static IEnumerable> QueryRange( + Stream stream, + bool useHeaderRow, + string? sheetName, + int startRowIndex, + int startColumnIndex, + int? endRowIndex = null, + int? endColumnIndex = null, + IConfiguration? configuration = null) => + MiniExcelRust.QueryRange( + stream, + useHeaderRow, + sheetName, + startRowIndex, + startColumnIndex, + endRowIndex, + endColumnIndex, + OpenXmlOptions(configuration), + leaveOpen: true); + + public static IAsyncEnumerable> QueryRangeAsync( + string path, + bool useHeaderRow, + string? sheetName, + int startRowIndex, + int startColumnIndex, + int? endRowIndex = null, + int? endColumnIndex = null, + IConfiguration? configuration = null, + CancellationToken cancellationToken = default) => + MiniExcelRust.QueryRangeAsync( + path, + useHeaderRow, + sheetName, + startRowIndex, + startColumnIndex, + endRowIndex, + endColumnIndex, + OpenXmlOptions(configuration), + cancellationToken); + + public static int[] SaveAs( + string path, + IEnumerable> rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool overwriteFile = false) => + [MiniExcelRust.SaveAs(path, rows, printHeader, sheetName, overwriteFile)]; + + public static int[] SaveAs( + string path, + IEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool overwriteFile = false) => + [MiniExcelRust.SaveAs(path, rows, printHeader, sheetName, overwriteFile)]; + + public static int[] SaveAs( + string path, + object value, + bool printHeader = true, + string sheetName = "Sheet1", + ExcelType excelType = ExcelType.UNKNOWN, + IConfiguration? configuration = null, + bool overwriteFile = false) + { + if (value is DataSet dataSet) + { + var sheets = dataSet.Tables.Cast().Select(table => + new KeyValuePair>>( + string.IsNullOrWhiteSpace(table.TableName) ? sheetName : table.TableName, + DataTableRows(table))); + return MiniExcelRust.SaveAsSheets(path, sheets, printHeader, overwriteFile); + } + if (excelType == ExcelType.CSV || IsCsv(path, excelType)) + return [MiniExcelRust.SaveAsCsv(path, ObjectRows(value))]; + return [MiniExcelRust.SaveAs(path, ObjectRows(value), printHeader, sheetName, overwriteFile)]; + } + + public static int[] SaveAs( + Stream stream, + IEnumerable> rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool leaveOpen = false) => + [MiniExcelRust.SaveAs(stream, rows, printHeader, sheetName, leaveOpen)]; + + public static int[] SaveAs( + Stream stream, + object value, + bool printHeader = true, + string sheetName = "Sheet1", + ExcelType excelType = ExcelType.XLSX, + IConfiguration? configuration = null) + { + if (value is DataSet dataSet) + { + var sheets = dataSet.Tables.Cast().Select(table => + new KeyValuePair>>( + string.IsNullOrWhiteSpace(table.TableName) ? sheetName : table.TableName, + DataTableRows(table))); + return MiniExcelRust.SaveAsSheets(stream, sheets, printHeader, leaveOpen: true); + } + if (excelType == ExcelType.CSV) + return [MiniExcelRust.SaveAsCsv(stream, ObjectRows(value), leaveOpen: true)]; + return [MiniExcelRust.SaveAs(stream, ObjectRows(value), printHeader, sheetName, leaveOpen: true)]; + } + + public static Task SaveAsAsync( + string path, + object value, + bool printHeader = true, + string sheetName = "Sheet1", + ExcelType excelType = ExcelType.UNKNOWN, + IConfiguration? configuration = null, + bool overwriteFile = false, + IProgress? progress = null, + CancellationToken cancellationToken = default) => + Task.Run( + () => SaveAs(path, value, printHeader, sheetName, excelType, configuration, overwriteFile), + cancellationToken); + + public static Task SaveAsAsync( + Stream stream, + object value, + bool printHeader = true, + string sheetName = "Sheet1", + ExcelType excelType = ExcelType.XLSX, + IConfiguration? configuration = null, + IProgress? progress = null, + CancellationToken cancellationToken = default) => + Task.Run(() => SaveAs(stream, value, printHeader, sheetName, excelType, configuration), cancellationToken); + + public static async Task SaveAsAsync( + string path, + IAsyncEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool overwriteFile = false, + CancellationToken cancellationToken = default) => + [await MiniExcelRust.SaveAsAsync( + path, + rows, + printHeader, + sheetName, + overwriteFile, + cancellationToken: cancellationToken).ConfigureAwait(false)]; + + public static async Task SaveAsAsync( + Stream stream, + IAsyncEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool leaveOpen = true, + CancellationToken cancellationToken = default) => + [await MiniExcelRust.SaveAsAsync( + stream, + rows, + printHeader, + sheetName, + leaveOpen, + cancellationToken: cancellationToken).ConfigureAwait(false)]; + + public static int Insert( + string path, + IEnumerable> rows, + string sheetName, + MiniExcelRustInsertOptions? options = null) => + MiniExcelRust.InsertSheet(path, rows, sheetName, options); + + public static int Insert( + string path, + object value, + string sheetName = "Sheet1", + ExcelType excelType = ExcelType.UNKNOWN, + IConfiguration? configuration = null, + bool printHeader = true, + bool overwriteSheet = false) => + MiniExcelRust.InsertSheet( + path, + ObjectRows(value), + sheetName, + new MiniExcelRustInsertOptions + { + PrintHeader = printHeader, + ReplaceExistingSheet = overwriteSheet + }); + + public static Task InsertAsync( + string path, + IEnumerable> rows, + string sheetName = "Sheet1", + MiniExcelRustInsertOptions? options = null, + CancellationToken cancellationToken = default) => + Task.Run(() => MiniExcelRust.InsertSheet(path, rows, sheetName, options), cancellationToken); + + public static int Insert( + Stream stream, + IEnumerable> rows, + string sheetName = "Sheet1", + MiniExcelRustInsertOptions? options = null, + bool leaveOpen = true) => + MiniExcelRust.InsertSheet(stream, rows, sheetName, options, leaveOpen); + + public static Task InsertAsync( + Stream stream, + IEnumerable> rows, + string sheetName = "Sheet1", + MiniExcelRustInsertOptions? options = null, + bool leaveOpen = true, + CancellationToken cancellationToken = default) => + Task.Run( + () => MiniExcelRust.InsertSheet(stream, rows, sheetName, options, leaveOpen), + cancellationToken); + + public static void SaveAsByTemplate( + string path, + string templatePath, + object value, + bool overwriteFile = false) => + MiniExcelRust.FillTemplate(path, templatePath, value, overwriteFile); + + public static Task SaveAsByTemplateAsync( + string path, + string templatePath, + object value, + bool overwriteFile = false, + CancellationToken cancellationToken = default) => + Task.Run(() => MiniExcelRust.FillTemplate(path, templatePath, value, overwriteFile), cancellationToken); + + public static void SaveAsByTemplate( + Stream stream, + byte[] templateBytes, + object value, + bool leaveOpen = false) => + MiniExcelRust.FillTemplate(stream, templateBytes, value, leaveOpen: leaveOpen); + + public static void SaveAsByTemplate( + string path, + byte[] templateBytes, + object value, + bool overwriteFile = false) => + MiniExcelRust.FillTemplate(path, templateBytes, value, overwriteFile); + + public static void SaveAsByTemplate( + string path, + Stream templateStream, + object value, + bool overwriteFile = false, + bool leaveTemplateOpen = true) => + MiniExcelRust.FillTemplate(path, templateStream, value, overwriteFile, leaveTemplateOpen: leaveTemplateOpen); + + public static void SaveAsByTemplate( + Stream stream, + string templatePath, + object value, + bool leaveOpen = true) => + MiniExcelRust.FillTemplate(stream, templatePath, value, leaveOpen: leaveOpen); + + public static void SaveAsByTemplate( + Stream stream, + Stream templateStream, + object value, + bool leaveOpen = true, + bool leaveTemplateOpen = true) => + MiniExcelRust.FillTemplate( + stream, + templateStream, + value, + leaveOpen: leaveOpen, + leaveTemplateOpen: leaveTemplateOpen); + + public static Task SaveAsByTemplateAsync( + string path, + byte[] templateBytes, + object value, + bool overwriteFile = false, + CancellationToken cancellationToken = default) => + Task.Run(() => MiniExcelRust.FillTemplate(path, templateBytes, value, overwriteFile), cancellationToken); + + public static Task SaveAsByTemplateAsync( + string path, + Stream templateStream, + object value, + bool overwriteFile = false, + bool leaveTemplateOpen = true, + CancellationToken cancellationToken = default) => + Task.Run( + () => MiniExcelRust.FillTemplate( + path, + templateStream, + value, + overwriteFile, + leaveTemplateOpen: leaveTemplateOpen), + cancellationToken); + + public static Task SaveAsByTemplateAsync( + Stream stream, + string templatePath, + object value, + bool leaveOpen = true, + CancellationToken cancellationToken = default) => + Task.Run(() => MiniExcelRust.FillTemplate(stream, templatePath, value, leaveOpen: leaveOpen), cancellationToken); + + public static Task SaveAsByTemplateAsync( + Stream stream, + byte[] templateBytes, + object value, + bool leaveOpen = true, + CancellationToken cancellationToken = default) => + Task.Run(() => MiniExcelRust.FillTemplate(stream, templateBytes, value, leaveOpen: leaveOpen), cancellationToken); + + public static Task SaveAsByTemplateAsync( + Stream stream, + Stream templateStream, + object value, + bool leaveOpen = true, + bool leaveTemplateOpen = true, + CancellationToken cancellationToken = default) => + Task.Run( + () => MiniExcelRust.FillTemplate( + stream, + templateStream, + value, + leaveOpen: leaveOpen, + leaveTemplateOpen: leaveTemplateOpen), + cancellationToken); + + public static void MergeSameCells( + string destinationPath, + string sourcePath, + bool overwriteFile = false) => + MiniExcelRust.MergeSameCells(destinationPath, sourcePath, overwriteFile); + + public static void MergeSameCells(Stream stream, string sourcePath) => + MiniExcelRust.MergeSameCells(stream, sourcePath, leaveOpen: true); + + public static void MergeSameCells(Stream stream, byte[] sourceBytes) => + MiniExcelRust.MergeSameCells(stream, sourceBytes, leaveOpen: true); + + public static Task MergeSameCellsAsync( + string destinationPath, + string sourcePath, + bool overwriteFile = false, + CancellationToken cancellationToken = default) => + Task.Run( + () => MiniExcelRust.MergeSameCells(destinationPath, sourcePath, overwriteFile), + cancellationToken); + + public static Task MergeSameCellsAsync( + Stream stream, + string sourcePath, + bool leaveOpen = true, + CancellationToken cancellationToken = default) => + Task.Run(() => MiniExcelRust.MergeSameCells(stream, sourcePath, leaveOpen), cancellationToken); + + public static Task MergeSameCellsAsync( + Stream stream, + byte[] sourceBytes, + bool leaveOpen = true, + CancellationToken cancellationToken = default) => + Task.Run(() => MiniExcelRust.MergeSameCells(stream, sourceBytes, leaveOpen), cancellationToken); + + public static IDataReader GetReader( + string path, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + ExcelType excelType = ExcelType.UNKNOWN, + IConfiguration? configuration = null) => + IsCsv(path, excelType) + ? MiniExcelRust.GetCsvReader(path, useHeaderRow, CsvOptions(configuration)) + : MiniExcelRust.GetReader(path, useHeaderRow, sheetName, startCell, OpenXmlOptions(configuration)); + + public static IDataReader GetReader( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + ExcelType excelType = ExcelType.XLSX, + string startCell = "A1", + IConfiguration? configuration = null, + bool leaveOpen = false) => + excelType == ExcelType.CSV + ? MiniExcelRust.GetCsvReader(stream, useHeaderRow, CsvOptions(configuration), leaveOpen) + : MiniExcelRust.GetReader(stream, useHeaderRow, sheetName, startCell, OpenXmlOptions(configuration), leaveOpen); + + public static DataTable QueryAsDataTable( + string path, + bool useHeaderRow = true, + string? sheetName = null, + string startCell = "A1", + ExcelType excelType = ExcelType.UNKNOWN, + IConfiguration? configuration = null) => + IsCsv(path, excelType) + ? MiniExcelRust.QueryCsvAsDataTable(path, useHeaderRow, CsvOptions(configuration)) + : MiniExcelRust.QueryAsDataTable(path, useHeaderRow, sheetName, startCell, OpenXmlOptions(configuration)); + + public static DataTable QueryAsDataTable( + Stream stream, + bool useHeaderRow = true, + string? sheetName = null, + ExcelType excelType = ExcelType.XLSX, + string startCell = "A1", + IConfiguration? configuration = null, + bool leaveOpen = false) => + excelType == ExcelType.CSV + ? MiniExcelRust.QueryCsvAsDataTable(stream, useHeaderRow, CsvOptions(configuration), leaveOpen) + : MiniExcelRust.QueryAsDataTable(stream, useHeaderRow, sheetName, startCell, OpenXmlOptions(configuration), leaveOpen); + + public static Task QueryAsDataTableAsync( + string path, + bool useHeaderRow = true, + string? sheetName = null, + string startCell = "A1", + ExcelType excelType = ExcelType.UNKNOWN, + IConfiguration? configuration = null, + CancellationToken cancellationToken = default) => + Task.Run( + () => QueryAsDataTable(path, useHeaderRow, sheetName, startCell, excelType, configuration), + cancellationToken); + + public static Task QueryAsDataTableAsync( + Stream stream, + bool useHeaderRow = true, + string? sheetName = null, + ExcelType excelType = ExcelType.XLSX, + string startCell = "A1", + IConfiguration? configuration = null, + bool leaveOpen = true, + CancellationToken cancellationToken = default) => + Task.Run( + () => QueryAsDataTable( + stream, + useHeaderRow, + sheetName, + excelType, + startCell, + configuration, + leaveOpen), + cancellationToken); + + public static List GetSheetNames(string path) => MiniExcelRust.GetSheetNames(path); + + public static List GetSheetNames(Stream stream, bool leaveOpen = false) => + MiniExcelRust.GetSheetNames(stream, leaveOpen); + + public static Task> GetSheetNamesAsync( + string path, + CancellationToken cancellationToken = default) => + MiniExcelRust.GetSheetNamesAsync(path, cancellationToken); + + public static Task> GetSheetNamesAsync( + Stream stream, + bool leaveOpen = false, + CancellationToken cancellationToken = default) => + Task.Run(() => MiniExcelRust.GetSheetNames(stream, leaveOpen), cancellationToken); + + public static List GetSheetInformations(string path) => + MiniExcelRust.GetSheetInformations(path); + + public static List GetSheetInformations(Stream stream, bool leaveOpen = false) => + MiniExcelRust.GetSheetInformations(stream, leaveOpen); + + public static Task> GetSheetInformationsAsync( + string path, + CancellationToken cancellationToken = default) => + MiniExcelRust.GetSheetInformationsAsync(path, cancellationToken); + + public static Task> GetSheetInformationsAsync( + Stream stream, + bool leaveOpen = true, + CancellationToken cancellationToken = default) => + Task.Run(() => MiniExcelRust.GetSheetInformations(stream, leaveOpen), cancellationToken); + + public static List GetSheetDimensions(string path) => + MiniExcelRust.GetSheetDimensions(path); + + public static List GetSheetDimensions(Stream stream, bool leaveOpen = false) => + MiniExcelRust.GetSheetDimensions(stream, leaveOpen); + + public static Task> GetSheetDimensionsAsync( + string path, + CancellationToken cancellationToken = default) => + MiniExcelRust.GetSheetDimensionsAsync(path, cancellationToken); + + public static Task> GetSheetDimensionsAsync( + Stream stream, + bool leaveOpen = true, + CancellationToken cancellationToken = default) => + Task.Run(() => MiniExcelRust.GetSheetDimensions(stream, leaveOpen), cancellationToken); + + public static List GetColumns( + string path, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + ExcelType excelType = ExcelType.UNKNOWN, + IConfiguration? configuration = null) => + IsCsv(path, excelType) + ? MiniExcelRust.GetCsvColumnNames(path, useHeaderRow, CsvOptions(configuration)) + : MiniExcelRust.GetColumnNames(path, useHeaderRow, sheetName, startCell); + + public static Task> GetColumnsAsync( + string path, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + ExcelType excelType = ExcelType.UNKNOWN, + IConfiguration? configuration = null, + CancellationToken cancellationToken = default) => + Task.Run>( + () => GetColumns(path, useHeaderRow, sheetName, startCell, excelType, configuration), + cancellationToken); + + public static List GetColumns( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + IConfiguration? configuration = null, + bool leaveOpen = true) => + MiniExcelRust.GetColumnNames( + stream, + useHeaderRow, + sheetName, + startCell, + leaveOpen); + + public static Task> GetColumnsAsync( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + IConfiguration? configuration = null, + bool leaveOpen = true, + CancellationToken cancellationToken = default) => + Task.Run>( + () => GetColumns(stream, useHeaderRow, sheetName, startCell, configuration, leaveOpen), + cancellationToken); + + public static void ConvertCsvToXlsx(string csvPath, string xlsxPath, bool csvHasHeader = false) => + MiniExcelRust.ConvertCsvToXlsx(csvPath, xlsxPath, csvHasHeader); + + public static void ConvertCsvToXlsx(Stream csvStream, Stream xlsxStream, bool csvHasHeader = false) => + MiniExcelRust.ConvertCsvToXlsx(csvStream, xlsxStream, csvHasHeader); + + public static Task ConvertCsvToXlsxAsync( + string csvPath, + string xlsxPath, + bool csvHasHeader = false, + CancellationToken cancellationToken = default) => + MiniExcelRust.ConvertCsvToXlsxAsync(csvPath, xlsxPath, csvHasHeader, cancellationToken); + + public static Task ConvertCsvToXlsxAsync( + Stream csvStream, + Stream xlsxStream, + bool csvHasHeader = false, + CancellationToken cancellationToken = default) => + Task.Run(() => MiniExcelRust.ConvertCsvToXlsx(csvStream, xlsxStream, csvHasHeader), cancellationToken); + + public static void ConvertXlsxToCsv(string xlsxPath, string csvPath, bool xlsxHasHeader = true) => + MiniExcelRust.ConvertXlsxToCsv(xlsxPath, csvPath, xlsxHasHeader); + + public static void ConvertXlsxToCsv(Stream xlsxStream, Stream csvStream, bool xlsxHasHeader = true) => + MiniExcelRust.ConvertXlsxToCsv(xlsxStream, csvStream, xlsxHasHeader); + + public static Task ConvertXlsxToCsvAsync( + string xlsxPath, + string csvPath, + bool xlsxHasHeader = true, + CancellationToken cancellationToken = default) => + MiniExcelRust.ConvertXlsxToCsvAsync(xlsxPath, csvPath, xlsxHasHeader, cancellationToken); + + public static Task ConvertXlsxToCsvAsync( + Stream xlsxStream, + Stream csvStream, + bool xlsxHasHeader = true, + CancellationToken cancellationToken = default) => + Task.Run(() => MiniExcelRust.ConvertXlsxToCsv(xlsxStream, csvStream, xlsxHasHeader), cancellationToken); + + private static bool IsCsv(string path, ExcelType excelType) => + excelType == ExcelType.CSV || + excelType == ExcelType.UNKNOWN && string.Equals(Path.GetExtension(path), ".csv", StringComparison.OrdinalIgnoreCase); + + private static MiniExcelRustReadOptions? OpenXmlOptions(IConfiguration? configuration) => + (configuration as OpenXml.OpenXmlConfiguration)?.ToReadOptions(); + + private static MiniExcelRustCsvReadOptions? CsvOptions(IConfiguration? configuration) => + (configuration as Csv.CsvConfiguration)?.ToReadOptions(); + + private static IEnumerable> ObjectRows(object value) + { + if (value is null) + throw new ArgumentNullException(nameof(value)); + if (value is DataTable table) + return DataTableRows(table); + if (value is IDictionary row) + return new[] { row }; + if (value is IEnumerable values && value is not string) + return MiniExcelRustMapper.ToRows(values); + return MiniExcelRustMapper.ToRows(new[] { value }); + } + + private static IEnumerable> DataTableRows(DataTable table) + { + var columns = table.Columns.Cast().Select(column => column.ColumnName).ToArray(); + foreach (DataRow dataRow in table.Rows) + { + IDictionary row = new Dictionary(StringComparer.Ordinal); + foreach (var column in columns) + row[column] = dataRow[column] is DBNull ? null : dataRow[column]; + yield return row; + } + } + + private static async IAsyncEnumerable ToFacadeAsync( + IEnumerable values, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (var value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return value; + await Task.Yield(); + } + } +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRust.cs b/src/MiniExcelRust/MiniExcelRust.cs index 01d0660..fc65b4e 100644 --- a/src/MiniExcelRust/MiniExcelRust.cs +++ b/src/MiniExcelRust/MiniExcelRust.cs @@ -1,7 +1,10 @@ using System.Collections; +using System.Data; using System.Globalization; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; +using System.Text.Json; using Microsoft.Win32.SafeHandles; namespace MiniExcelLibs; @@ -13,10 +16,306 @@ public static class MiniExcelRust { private const int BatchSize = 64; + public static IAsyncEnumerable> QueryAsync( + string path, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null, + CancellationToken cancellationToken = default) + { + return ToAsyncEnumerable( + Query(path, useHeaderRow, sheetName, startCell, configuration), + cancellationToken); + } + + public static IAsyncEnumerable QueryAsync( + string path, + string? sheetName = null, + string startCell = "A1", + bool treatHeaderAsData = false, + MiniExcelRustReadOptions? configuration = null, + CancellationToken cancellationToken = default) + where T : class, new() + { + return ToAsyncEnumerable( + Query(path, sheetName, startCell, treatHeaderAsData, configuration), + cancellationToken); + } + + public static IAsyncEnumerable> QueryAsync( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false, + CancellationToken cancellationToken = default) => + ToAsyncEnumerable( + Query(stream, useHeaderRow, sheetName, startCell, configuration, leaveOpen), + cancellationToken); + + public static IAsyncEnumerable QueryAsync( + Stream stream, + string? sheetName = null, + string startCell = "A1", + bool treatHeaderAsData = false, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false, + CancellationToken cancellationToken = default) + where T : class, new() => + ToAsyncEnumerable( + Query(stream, sheetName, startCell, treatHeaderAsData, configuration, leaveOpen), + cancellationToken); + + public static IAsyncEnumerable> QueryRangeAsync( + string path, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + MiniExcelRustReadOptions? configuration = null, + CancellationToken cancellationToken = default) + { + return ToAsyncEnumerable( + QueryRange(path, useHeaderRow, sheetName, startCell, endCell, configuration), + cancellationToken); + } + + public static IAsyncEnumerable> QueryRangeAsync( + Stream stream, + bool useHeaderRow, + string? sheetName, + int startRowIndex, + int startColumnIndex, + int? endRowIndex = null, + int? endColumnIndex = null, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false, + CancellationToken cancellationToken = default) => + ToAsyncEnumerable( + QueryRange( + stream, + useHeaderRow, + sheetName, + startRowIndex, + startColumnIndex, + endRowIndex, + endColumnIndex, + configuration, + leaveOpen), + cancellationToken); + + public static IAsyncEnumerable> QueryRangeAsync( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false, + CancellationToken cancellationToken = default) => + ToAsyncEnumerable( + QueryRange(stream, useHeaderRow, sheetName, startCell, endCell, configuration, leaveOpen), + cancellationToken); + + public static IAsyncEnumerable> QueryTableAsync( + string path, + string? sheetName = null, + string tableName = "Table1", + CancellationToken cancellationToken = default) + { + return ToAsyncEnumerable(QueryTable(path, sheetName, tableName), cancellationToken); + } + + public static IAsyncEnumerable QueryTableAsync( + string path, + string? sheetName = null, + string tableName = "Table1", + CancellationToken cancellationToken = default) + where T : class, new() => + ToAsyncEnumerable(QueryTable(path, sheetName, tableName), cancellationToken); + + public static IAsyncEnumerable> QueryTableAsync( + Stream stream, + string? sheetName = null, + string tableName = "Table1", + bool leaveOpen = false, + CancellationToken cancellationToken = default) => + ToAsyncEnumerable(QueryTable(stream, sheetName, tableName, leaveOpen), cancellationToken); + + public static IAsyncEnumerable> QueryCsvAsync( + string path, + bool useHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null, + CancellationToken cancellationToken = default) + { + return ToAsyncEnumerable(QueryCsv(path, useHeaderRow, configuration), cancellationToken); + } + + public static IAsyncEnumerable QueryCsvAsync( + string path, + bool treatHeaderAsData = false, + MiniExcelRustCsvReadOptions? configuration = null, + CancellationToken cancellationToken = default) + where T : class, new() => + ToAsyncEnumerable(QueryCsv(path, treatHeaderAsData, configuration), cancellationToken); + + public static IEnumerable Query( + string path, + string? sheetName = null, + string startCell = "A1", + bool treatHeaderAsData = false, + MiniExcelRustReadOptions? configuration = null) + where T : class, new() + { + return MiniExcelRustMapper.Map( + Query(path, !treatHeaderAsData, sheetName, startCell, configuration), + configuration?.Culture, + configuration?.DynamicColumns as IReadOnlyDictionary); + } + + public static IEnumerable Query( + Stream stream, + string? sheetName = null, + string startCell = "A1", + bool treatHeaderAsData = false, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + where T : class, new() + { + return MiniExcelRustMapper.Map( + Query(stream, !treatHeaderAsData, sheetName, startCell, configuration, leaveOpen), + configuration?.Culture, + configuration?.DynamicColumns as IReadOnlyDictionary); + } + + public static IEnumerable QueryRange( + string path, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + bool treatHeaderAsData = false, + MiniExcelRustReadOptions? configuration = null) + where T : class, new() + { + return MiniExcelRustMapper.Map( + QueryRange(path, !treatHeaderAsData, sheetName, startCell, endCell, configuration), + configuration?.Culture, + configuration?.DynamicColumns as IReadOnlyDictionary); + } + + public static IEnumerable QueryRange( + Stream stream, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + bool treatHeaderAsData = false, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + where T : class, new() => + MiniExcelRustMapper.Map( + QueryRange( + stream, + !treatHeaderAsData, + sheetName, + startCell, + endCell, + configuration, + leaveOpen), + configuration?.Culture, + configuration?.DynamicColumns as IReadOnlyDictionary); + + public static IEnumerable QueryTable( + string path, + string? sheetName = null, + string tableName = "Table1") + where T : class, new() + { + return MiniExcelRustMapper.Map(QueryTable(path, sheetName, tableName)); + } + + public static IEnumerable QueryTable( + Stream stream, + string? sheetName = null, + string tableName = "Table1", + bool leaveOpen = false) + where T : class, new() => + MiniExcelRustMapper.Map(QueryTable(stream, sheetName, tableName, leaveOpen)); + + public static IEnumerable QueryCsv( + string path, + bool treatHeaderAsData = false, + MiniExcelRustCsvReadOptions? configuration = null) + where T : class, new() + { + return MiniExcelRustMapper.Map( + QueryCsv(path, !treatHeaderAsData, configuration), + configuration?.Culture, + configuration?.DynamicColumns as IReadOnlyDictionary); + } + + public static IEnumerable QueryCsv( + Stream stream, + bool treatHeaderAsData = false, + MiniExcelRustCsvReadOptions? configuration = null, + bool leaveOpen = false) + where T : class, new() + { + return MiniExcelRustMapper.Map( + QueryCsv(stream, !treatHeaderAsData, configuration, leaveOpen), + configuration?.Culture, + configuration?.DynamicColumns as IReadOnlyDictionary); + } + /// - /// Streams rows from an XLSX file through the native Rust query engine. + /// Returns worksheet names in workbook order. /// - public static IEnumerable> Query( + public static List GetSheetNames(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + + EnsureAbiVersion(); + + using var nativePath = new Utf8String(Path.GetFullPath(path)); + var result = NativeMethods.GetSheetNames( + nativePath.Pointer, + out var rawHandle, + out var data, + out var length); + if (result < 0) + throw CreateNativeException(result); + + using var handle = new NativeBufferHandle(rawHandle); + var byteLength = checked((int)length.ToUInt64()); + var frame = new byte[byteLength]; + Marshal.Copy(data, frame, 0, byteLength); + return DecodeStrings(frame); + } + + /// + /// Returns worksheet names from a stream in workbook order. + /// + public static List GetSheetNames(Stream stream, bool leaveOpen = false) + { + return UseStagedStream(stream, leaveOpen, GetSheetNames); + } + + /// + /// Asynchronously returns worksheet names in workbook order. + /// + public static Task> GetSheetNamesAsync( + string path, + CancellationToken cancellationToken = default) + { + return Task.Run(() => GetSheetNames(path), cancellationToken); + } + + /// + /// Returns selected column names from an XLSX worksheet. + /// + public static List GetColumnNames( string path, bool useHeaderRow = false, string? sheetName = null, @@ -27,33 +326,1961 @@ public static class MiniExcelRust if (string.IsNullOrWhiteSpace(startCell)) throw new ArgumentException("The start cell is required.", nameof(startCell)); - return QueryIterator(Path.GetFullPath(path), useHeaderRow, sheetName, startCell); + EnsureAbiVersion(); + + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + using var nativeStartCell = new Utf8String(startCell); + var result = NativeMethods.GetColumns( + nativePath.Pointer, + useHeaderRow ? (byte)1 : (byte)0, + nativeSheetName.Pointer, + nativeStartCell.Pointer, + out var rawHandle, + out var data, + out var length); + if (result < 0) + throw CreateNativeException(result); + + using var handle = new NativeBufferHandle(rawHandle); + var byteLength = checked((int)length.ToUInt64()); + var frame = new byte[byteLength]; + Marshal.Copy(data, frame, 0, byteLength); + return DecodeStrings(frame); } - private static IEnumerable> QueryIterator( + /// + /// Returns selected column names from an XLSX stream. + /// + public static List GetColumnNames( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + bool leaveOpen = false) + { + return UseStagedStream( + stream, + leaveOpen, + path => GetColumnNames(path, useHeaderRow, sheetName, startCell)); + } + + /// + /// Asynchronously returns selected column names from an XLSX worksheet. + /// + public static Task> GetColumnNamesAsync( string path, - bool useHeaderRow, - string? sheetName, - string startCell) + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + CancellationToken cancellationToken = default) + { + return Task.Run( + () => GetColumnNames(path, useHeaderRow, sheetName, startCell), + cancellationToken); + } + + /// + /// Returns the used range of every worksheet in workbook order. + /// + public static List GetSheetDimensions(string path) { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + EnsureAbiVersion(); - using var nativePath = new Utf8String(path); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + var result = NativeMethods.GetSheetDimensions( + nativePath.Pointer, + out var rawHandle, + out var data, + out var length); + if (result < 0) + throw CreateNativeException(result); + + using var handle = new NativeBufferHandle(rawHandle); + var byteLength = checked((int)length.ToUInt64()); + var frame = new byte[byteLength]; + Marshal.Copy(data, frame, 0, byteLength); + return DecodeRanges(frame); + } + + /// + /// Returns the used range of every worksheet in an XLSX stream. + /// + public static List GetSheetDimensions(Stream stream, bool leaveOpen = false) + { + return UseStagedStream(stream, leaveOpen, GetSheetDimensions); + } + + /// + /// Asynchronously returns the used range of every worksheet in workbook order. + /// + public static Task> GetSheetDimensionsAsync( + string path, + CancellationToken cancellationToken = default) + { + return Task.Run(() => GetSheetDimensions(path), cancellationToken); + } + + /// + /// Returns detailed information for every sheet in workbook order. + /// + public static List GetSheetInformations(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + + EnsureAbiVersion(); + + using var nativePath = new Utf8String(Path.GetFullPath(path)); + var result = NativeMethods.GetSheetInfo( + nativePath.Pointer, + out var rawHandle, + out var data, + out var length); + if (result < 0) + throw CreateNativeException(result); + + using var handle = new NativeBufferHandle(rawHandle); + var byteLength = checked((int)length.ToUInt64()); + var frame = new byte[byteLength]; + Marshal.Copy(data, frame, 0, byteLength); + return DecodeSheetInfo(frame); + } + + /// + /// Returns detailed information for every sheet in an XLSX stream. + /// + public static List GetSheetInformations( + Stream stream, + bool leaveOpen = false) + { + return UseStagedStream(stream, leaveOpen, GetSheetInformations); + } + + /// + /// Asynchronously returns detailed information for every sheet in workbook order. + /// + public static Task> GetSheetInformationsAsync( + string path, + CancellationToken cancellationToken = default) + { + return Task.Run(() => GetSheetInformations(path), cancellationToken); + } + + public static IDictionary ReadMapped( + string path, + IReadOnlyDictionary mapping, + string? sheetName = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (mapping is null || mapping.Count == 0) + throw new ArgumentException("At least one cell mapping is required.", nameof(mapping)); + + EnsureAbiVersion(); + var mappingFrame = EncodeMapping(mapping); + using var nativePath = new Utf8String(Path.GetFullPath(path)); using var nativeSheetName = new Utf8String(sheetName); - using var nativeStartCell = new Utf8String(startCell); - var result = NativeMethods.QueryOpen( + var mappingHandle = GCHandle.Alloc(mappingFrame, GCHandleType.Pinned); + try + { + var result = NativeMethods.ReadMapped( + nativePath.Pointer, + nativeSheetName.Pointer, + mappingHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)mappingFrame.Length, + out var rawHandle, + out var data, + out var length); + if (result < 0) + throw CreateNativeException(result); + using var handle = new NativeBufferHandle(rawHandle); + var frame = new byte[checked((int)length.ToUInt64())]; + Marshal.Copy(data, frame, 0, frame.Length); + return DecodeBatch(frame).Single(); + } + finally + { + mappingHandle.Free(); + } + } + + public static T ReadMapped( + string path, + IReadOnlyDictionary mapping, + string? sheetName = null) + where T : class, new() => + MiniExcelRustMapper.Map(new[] { ReadMapped(path, mapping, sheetName) }).Single(); + + public static T ReadMapped( + Stream stream, + IReadOnlyDictionary mapping, + string? sheetName = null, + bool leaveOpen = false) + where T : class, new() => + UseStagedStream(stream, leaveOpen, path => ReadMapped(path, mapping, sheetName)); + + /// + /// Returns threaded comments, replies, and legacy notes from an XLSX worksheet. + /// + public static MiniExcelRustCommentResult RetrieveComments(string path, string? sheetName = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + + EnsureAbiVersion(); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + var result = NativeMethods.GetComments( nativePath.Pointer, - useHeaderRow ? (byte)1 : (byte)0, nativeSheetName.Pointer, - nativeStartCell.Pointer, - out var rawHandle); + out var rawHandle, + out var data, + out var length); if (result < 0) throw CreateNativeException(result); + using var handle = new NativeBufferHandle(rawHandle); + var byteLength = checked((int)length.ToUInt64()); + var frame = new byte[byteLength]; + Marshal.Copy(data, frame, 0, byteLength); + return DecodeComments(frame); + } + + /// + /// Returns threaded comments, replies, and legacy notes from an XLSX stream. + /// + public static MiniExcelRustCommentResult RetrieveComments( + Stream stream, + string? sheetName = null, + bool leaveOpen = false) + { + return UseStagedStream(stream, leaveOpen, path => RetrieveComments(path, sheetName)); + } + + /// + /// Asynchronously returns comments and notes from an XLSX worksheet. + /// + public static Task RetrieveCommentsAsync( + string path, + string? sheetName = null, + CancellationToken cancellationToken = default) + { + return Task.Run(() => RetrieveComments(path, sheetName), cancellationToken); + } + + /// + /// Materializes an XLSX query as a DataTable. + /// + public static DataTable QueryAsDataTable( + string path, + bool hasHeaderRow = true, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + + var fullPath = Path.GetFullPath(path); + var columns = GetColumnNames(fullPath, hasHeaderRow, sheetName, startCell); + var rows = Query(fullPath, hasHeaderRow, sheetName, startCell, configuration); + return CreateDataTable(columns, rows); + } + + /// + /// Materializes an XLSX stream query as a DataTable. + /// + public static DataTable QueryAsDataTable( + Stream stream, + bool hasHeaderRow = true, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + { + return UseStagedStream( + stream, + leaveOpen, + path => QueryAsDataTable(path, hasHeaderRow, sheetName, startCell, configuration)); + } + + /// + /// Returns a DataReader over a materialized Rust-backed XLSX query. + /// + public static IDataReader GetReader( + string path, + bool hasHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null) + { + if (sheetName is not null) + return QueryAsDataTable(path, hasHeaderRow, sheetName, startCell, configuration).CreateDataReader(); + + var dataSet = new DataSet(); + foreach (var name in GetSheetNames(path)) + { + var table = QueryAsDataTable(path, hasHeaderRow, name, startCell, configuration); + table.TableName = name; + dataSet.Tables.Add(table); + } + return dataSet.CreateDataReader(); + } + + /// + /// Returns a DataReader over a materialized Rust-backed XLSX stream query. + /// + public static IDataReader GetReader( + Stream stream, + bool hasHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + { + return UseStagedStream( + stream, + leaveOpen, + path => GetReader(path, hasHeaderRow, sheetName, startCell, configuration)); + } + + /// + /// Streams rows from an XLSX file through the native Rust query engine. + /// + public static IEnumerable> Query( + string path, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (string.IsNullOrWhiteSpace(startCell)) + throw new ArgumentException("The start cell is required.", nameof(startCell)); + + return QueryIterator(Path.GetFullPath(path), useHeaderRow, sheetName, startCell, null, configuration); + } + + /// + /// Streams rows from an XLSX stream through the native Rust query engine. + /// + public static IEnumerable> Query( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + { + ValidateReadableStream(stream); + if (string.IsNullOrWhiteSpace(startCell)) + throw new ArgumentException("The start cell is required.", nameof(startCell)); + + return QueryStreamIterator(stream, useHeaderRow, sheetName, startCell, null, configuration, leaveOpen); + } + + /// + /// Streams rows from an inclusive XLSX cell range through the native Rust query engine. + /// + public static IEnumerable> QueryRange( + string path, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + MiniExcelRustReadOptions? configuration = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (string.IsNullOrWhiteSpace(startCell)) + throw new ArgumentException("The start cell is required.", nameof(startCell)); + if (endCell is not null && string.IsNullOrWhiteSpace(endCell)) + throw new ArgumentException("The end cell cannot be empty.", nameof(endCell)); + + return QueryIterator(Path.GetFullPath(path), useHeaderRow, sheetName, startCell, endCell, configuration); + } + + /// + /// Streams rows from an inclusive XLSX range in a stream through the native Rust query engine. + /// + public static IEnumerable> QueryRange( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + { + ValidateReadableStream(stream); + if (string.IsNullOrWhiteSpace(startCell)) + throw new ArgumentException("The start cell is required.", nameof(startCell)); + if (endCell is not null && string.IsNullOrWhiteSpace(endCell)) + throw new ArgumentException("The end cell cannot be empty.", nameof(endCell)); + + return QueryStreamIterator(stream, useHeaderRow, sheetName, startCell, endCell, configuration, leaveOpen); + } + + public static IEnumerable> QueryRange( + string path, + bool useHeaderRow, + string? sheetName, + int startRowIndex, + int startColumnIndex, + int? endRowIndex = null, + int? endColumnIndex = null, + MiniExcelRustReadOptions? configuration = null) + { + var startCell = ToCellReference(startRowIndex, startColumnIndex); + var endCell = endRowIndex.HasValue || endColumnIndex.HasValue + ? ToCellReference(endRowIndex ?? 1_048_576, endColumnIndex ?? 16_384) + : null; + return QueryRange(path, useHeaderRow, sheetName, startCell, endCell, configuration); + } + + public static IEnumerable> QueryRange( + Stream stream, + bool useHeaderRow, + string? sheetName, + int startRowIndex, + int startColumnIndex, + int? endRowIndex = null, + int? endColumnIndex = null, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + { + var startCell = ToCellReference(startRowIndex, startColumnIndex); + var endCell = endRowIndex.HasValue || endColumnIndex.HasValue + ? ToCellReference(endRowIndex ?? 1_048_576, endColumnIndex ?? 16_384) + : null; + return QueryRange( + stream, + useHeaderRow, + sheetName, + startCell, + endCell, + configuration, + leaveOpen); + } + + public static IAsyncEnumerable> QueryRangeAsync( + string path, + bool useHeaderRow, + string? sheetName, + int startRowIndex, + int startColumnIndex, + int? endRowIndex = null, + int? endColumnIndex = null, + MiniExcelRustReadOptions? configuration = null, + CancellationToken cancellationToken = default) => + ToAsyncEnumerable( + QueryRange( + path, + useHeaderRow, + sheetName, + startRowIndex, + startColumnIndex, + endRowIndex, + endColumnIndex, + configuration), + cancellationToken); + + /// + /// Streams rows from a named OpenXML table. + /// + public static IEnumerable> QueryTable( + string path, + string? sheetName = null, + string tableName = "Table1") + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (string.IsNullOrWhiteSpace(tableName)) + throw new ArgumentException("The table name is required.", nameof(tableName)); + + return QueryTableIterator(Path.GetFullPath(path), sheetName, tableName); + } + + /// + /// Streams rows from a named OpenXML table in a stream. + /// + public static IEnumerable> QueryTable( + Stream stream, + string? sheetName = null, + string tableName = "Table1", + bool leaveOpen = false) + { + ValidateReadableStream(stream); + if (string.IsNullOrWhiteSpace(tableName)) + throw new ArgumentException("The table name is required.", nameof(tableName)); + + return QueryTableStreamIterator(stream, sheetName, tableName, leaveOpen); + } + + /// + /// Streams rows from a CSV file through the native Rust query engine. + /// + public static IEnumerable> QueryCsv( + string path, + bool useHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + ValidateCsvConfiguration(configuration); + + return QueryCsvIterator(Path.GetFullPath(path), useHeaderRow, configuration); + } + + /// + /// Streams rows from a CSV stream through the native Rust query engine. + /// + public static IEnumerable> QueryCsv( + Stream stream, + bool useHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null, + bool leaveOpen = false) + { + ValidateReadableStream(stream); + ValidateCsvConfiguration(configuration); + + return QueryCsvStreamIterator(stream, useHeaderRow, configuration, leaveOpen); + } + + /// + /// Returns selected column names from a CSV file. + /// + public static List GetCsvColumnNames( + string path, + bool useHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + ValidateCsvConfiguration(configuration); + EnsureAbiVersion(); + configuration ??= new MiniExcelRustCsvReadOptions(); + + using var nativePath = new Utf8String(Path.GetFullPath(path)); + var result = NativeMethods.GetCsvColumns( + nativePath.Pointer, + useHeaderRow ? (byte)1 : (byte)0, + (byte)configuration.Delimiter, + (byte)configuration.Encoding, + configuration.ReadEmptyStringAsNull ? (byte)1 : (byte)0, + configuration.TrimColumnNames ? (byte)1 : (byte)0, + out var rawHandle, + out var data, + out var length); + if (result < 0) + throw CreateNativeException(result); + + using var handle = new NativeBufferHandle(rawHandle); + var byteLength = checked((int)length.ToUInt64()); + var frame = new byte[byteLength]; + Marshal.Copy(data, frame, 0, byteLength); + return DecodeStrings(frame); + } + + /// + /// Returns selected column names from a CSV stream. + /// + public static List GetCsvColumnNames( + Stream stream, + bool useHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null, + bool leaveOpen = false) + { + return UseStagedStream( + stream, + leaveOpen, + path => GetCsvColumnNames(path, useHeaderRow, configuration)); + } + + /// + /// Asynchronously returns selected column names from a CSV file. + /// + public static Task> GetCsvColumnNamesAsync( + string path, + bool useHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null, + CancellationToken cancellationToken = default) + { + return Task.Run( + () => GetCsvColumnNames(path, useHeaderRow, configuration), + cancellationToken); + } + + /// + /// Materializes a CSV query as a DataTable. + /// + public static DataTable QueryCsvAsDataTable( + string path, + bool hasHeaderRow = true, + MiniExcelRustCsvReadOptions? configuration = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + + var fullPath = Path.GetFullPath(path); + var columns = GetCsvColumnNames(fullPath, hasHeaderRow, configuration); + var rows = QueryCsv(fullPath, hasHeaderRow, configuration); + return CreateDataTable(columns, rows); + } + + /// + /// Materializes a CSV stream query as a DataTable. + /// + public static DataTable QueryCsvAsDataTable( + Stream stream, + bool hasHeaderRow = true, + MiniExcelRustCsvReadOptions? configuration = null, + bool leaveOpen = false) + { + return UseStagedStream( + stream, + leaveOpen, + path => QueryCsvAsDataTable(path, hasHeaderRow, configuration)); + } + + /// + /// Returns a DataReader over a materialized Rust-backed CSV query. + /// + public static IDataReader GetCsvReader( + string path, + bool hasHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null) + { + return QueryCsvAsDataTable(path, hasHeaderRow, configuration).CreateDataReader(); + } + + /// + /// Returns a DataReader over a materialized Rust-backed CSV stream query. + /// + public static IDataReader GetCsvReader( + Stream stream, + bool hasHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null, + bool leaveOpen = false) + { + return QueryCsvAsDataTable(stream, hasHeaderRow, configuration, leaveOpen).CreateDataReader(); + } + + /// + /// Creates a single-sheet XLSX workbook from dynamic rows. + /// + public static int SaveAs( + string path, + IEnumerable> rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool overwriteFile = false, + IProgress? progress = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (string.IsNullOrWhiteSpace(sheetName)) + throw new ArgumentException("The sheet name is required.", nameof(sheetName)); + + EnsureAbiVersion(); + var materializedRows = rows.ToList(); + var frame = EncodeRows(materializedRows); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + var frameHandle = GCHandle.Alloc(frame, GCHandleType.Pinned); + try + { + var result = NativeMethods.SaveAs( + nativePath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + printHeader ? (byte)1 : (byte)0, + nativeSheetName.Pointer, + overwriteFile ? (byte)1 : (byte)0, + out var rowCount); + if (result < 0) + throw CreateNativeException(result); + ReportProgress(progress, materializedRows); + return checked((int)rowCount); + } + finally + { + frameHandle.Free(); + } + } + + public static int SaveAs( + string path, + IEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool overwriteFile = false, + IProgress? progress = null) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (rows is IEnumerable> dynamicRows) + return SaveAs(path, dynamicRows, printHeader, sheetName, overwriteFile, progress); + return SaveAs( + path, + MiniExcelRustMapper.ToRows(rows), + printHeader, + sheetName, + overwriteFile, + progress); + } + + public static int SaveAs( + Stream stream, + IEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool leaveOpen = false, + IProgress? progress = null) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (rows is IEnumerable> dynamicRows) + return SaveAs(stream, dynamicRows, printHeader, sheetName, leaveOpen, progress); + return SaveAs(stream, MiniExcelRustMapper.ToRows(rows), printHeader, sheetName, leaveOpen, progress); + } + + public static async Task SaveAsAsync( + string path, + IAsyncEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool overwriteFile = false, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + cancellationToken.ThrowIfCancellationRequested(); + var spoolPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-spool-{Guid.NewGuid():N}.bin"); + try + { + List? schema = null; + long cellCount = 0; + using (var spool = new FileStream( + spoolPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 81920, + useAsync: true)) + { + await foreach (var value in rows.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + var row = value is IDictionary dynamicRow + ? dynamicRow + : MiniExcelRustMapper.ToRows(new[] { value }).Single(); + schema ??= row.Keys.ToList(); + cellCount += row.Count; + var frame = EncodeRows(new[] { row }); + var length = BitConverter.GetBytes(checked((uint)frame.Length)); + await spool.WriteAsync(length, 0, length.Length, cancellationToken).ConfigureAwait(false); + await spool.WriteAsync(frame, 0, frame.Length, cancellationToken).ConfigureAwait(false); + } + } + if (schema is null) + throw new InvalidOperationException("Async export requires at least one row to infer its schema."); + + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + schema, + sheetName, + overwriteFile, + printHeader + }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSpoolPath = new Utf8String(spoolPath); + using var nativeCancellation = NativeCancellationHandle.Create(); + using var registration = cancellationToken.Register( + static state => NativeMethods.Cancel((NativeCancellationHandle)state!), + nativeCancellation); + var payloadHandle = GCHandle.Alloc(payload, GCHandleType.Pinned); + try + { + var nativeResult = await Task.Run(() => + { + var result = NativeMethods.SaveAsSpooledAsync( + nativePath.Pointer, + nativeSpoolPath.Pointer, + payloadHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)payload.Length, + nativeCancellation, + out var rowCount); + return (Result: result, RowCount: rowCount); + }).ConfigureAwait(false); + if (nativeResult.Result < 0) + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + throw CreateNativeException(nativeResult.Result); + } + if (progress is not null) + { + for (long index = 0; index < cellCount; index++) + progress.Report(1); + } + return checked((int)nativeResult.RowCount); + } + finally + { + payloadHandle.Free(); + } + } + finally + { + DeleteTemporaryFile(spoolPath); + } + } + + public static async Task SaveAsAsync( + Stream stream, + IAsyncEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool leaveOpen = false, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ValidateWritableStream(stream); + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + try + { + var count = await SaveAsAsync( + temporaryPath, + rows, + printHeader, + sheetName, + false, + progress, + cancellationToken).ConfigureAwait(false); + CopyFileToStream(temporaryPath, stream); + return count; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static int[] SaveAsSheets( + string path, + IEnumerable>>> sheets, + bool printHeader = true, + bool overwriteFile = false) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (sheets is null) + throw new ArgumentNullException(nameof(sheets)); + + EnsureAbiVersion(); + var frame = EncodeSheets(sheets); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + var frameHandle = GCHandle.Alloc(frame, GCHandleType.Pinned); + try + { + var result = NativeMethods.SaveAsSheets( + nativePath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + printHeader ? (byte)1 : (byte)0, + overwriteFile ? (byte)1 : (byte)0, + out var rawHandle, + out var data, + out var length); + if (result < 0) + throw CreateNativeException(result); + using var handle = new NativeBufferHandle(rawHandle); + var byteLength = checked((int)length.ToUInt64()); + var resultFrame = new byte[byteLength]; + Marshal.Copy(data, resultFrame, 0, byteLength); + var reader = new FrameReader(resultFrame); + var count = reader.ReadLength(); + var rowCounts = new int[count]; + for (var index = 0; index < count; index++) + rowCounts[index] = reader.ReadLength(); + reader.EnsureComplete(); + return rowCounts; + } + finally + { + frameHandle.Free(); + } + } + + public static int[] SaveAsSheets( + Stream stream, + IEnumerable>>> sheets, + bool printHeader = true, + bool leaveOpen = false) + { + ValidateWritableStream(stream); + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + try + { + var rowCounts = SaveAsSheets(temporaryPath, sheets, printHeader); + CopyFileToStream(temporaryPath, stream); + return rowCounts; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static int SaveAsWithSchema( + string path, + IReadOnlyList schema, + IEnumerable> rows, + MiniExcelRustWriteOptions? options = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (schema is null) + throw new ArgumentNullException(nameof(schema)); + if (schema.Count == 0 || schema.Any(string.IsNullOrWhiteSpace)) + throw new ArgumentException("The schema must contain at least one named column.", nameof(schema)); + if (schema.Distinct(StringComparer.Ordinal).Count() != schema.Count) + throw new ArgumentException("Schema column names must be unique.", nameof(schema)); + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + options ??= new MiniExcelRustWriteOptions(); + var formulaColumns = options.DynamicColumns + .Where(column => column.Value.IsFormula) + .Select(column => string.IsNullOrWhiteSpace(column.Value.Name) ? column.Key : column.Value.Name!) + .ToArray(); + + EnsureAbiVersion(); + var frame = EncodeRows(rows); + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + schema, + options.SheetName, + options.OverwriteFile, + options.PrintHeader, + options.AutoFilter, + options.RightToLeft, + options.AutoWidth, + options.WrapCellContents, + horizontalAlignment = options.HorizontalAlignment.ToString().ToLowerInvariant(), + verticalAlignment = options.VerticalAlignment.ToString().ToLowerInvariant(), + tableStyle = options.TableStyle.ToString().ToLowerInvariant(), + options.HeaderWrapText, + options.HeaderBackgroundColor, + headerHorizontalAlignment = options.HeaderHorizontalAlignment.ToString().ToLowerInvariant(), + headerVerticalAlignment = options.HeaderVerticalAlignment.ToString().ToLowerInvariant(), + options.MinWidth, + options.MaxWidth, + options.FreezeRowCount, + options.FreezeColumnCount, + options.DateFormat, + options.TimeFormat, + options.DateTimeFormat, + options.DurationFormat, + options.ColumnFormats, + options.ColumnWidths, + options.HiddenColumns, + formulaColumns + }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + var frameHandle = GCHandle.Alloc(frame, GCHandleType.Pinned); + var payloadHandle = GCHandle.Alloc(payload, GCHandleType.Pinned); + try + { + var result = NativeMethods.SaveAsConfigured( + nativePath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + payloadHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)payload.Length, + out var rowCount); + if (result < 0) + throw CreateNativeException(result); + return checked((int)rowCount); + } + finally + { + payloadHandle.Free(); + frameHandle.Free(); + } + } + + public static int SaveAs( + string path, + IEnumerable rows, + MiniExcelRustWriteOptions options) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (options is null) + throw new ArgumentNullException(nameof(options)); + var dynamicColumns = options.DynamicColumns as IReadOnlyDictionary; + var dynamicRows = MiniExcelRustMapper.ToRows(rows, dynamicColumns).ToList(); + if (dynamicRows.Count == 0) + throw new ArgumentException("Typed configured export requires at least one row.", nameof(rows)); + var schema = dynamicRows[0].Keys.ToList(); + return SaveAsWithSchema(path, schema, dynamicRows, options); + } + + /// + /// Creates a single-sheet XLSX workbook and copies it to a writable stream. + /// + public static int SaveAs( + Stream stream, + IEnumerable> rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool leaveOpen = false, + IProgress? progress = null) + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + if (!stream.CanWrite) + throw new ArgumentException("The stream must be writable.", nameof(stream)); + + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + try + { + var rowCount = SaveAs(temporaryPath, rows, printHeader, sheetName, progress: progress); + using var input = File.OpenRead(temporaryPath); + input.CopyTo(stream); + return rowCount; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + /// + /// Creates a CSV file from dynamic rows. + /// + public static int SaveAsCsv( + string path, + IEnumerable> rows, + MiniExcelRustCsvWriteOptions? configuration = null) + { + return WriteCsv(path, rows, configuration, append: false); + } + + public static int SaveAsCsv( + string path, + IEnumerable rows, + MiniExcelRustCsvWriteOptions? configuration = null) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (rows is IEnumerable> dynamicRows) + return SaveAsCsv(path, dynamicRows, configuration); + return SaveAsCsv(path, MiniExcelRustMapper.ToRows(rows), configuration); + } + + public static int SaveAsCsv( + Stream stream, + IEnumerable rows, + MiniExcelRustCsvWriteOptions? configuration = null, + bool leaveOpen = false) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (rows is IEnumerable> dynamicRows) + return SaveAsCsv(stream, dynamicRows, configuration, leaveOpen); + return SaveAsCsv(stream, MiniExcelRustMapper.ToRows(rows), configuration, leaveOpen); + } + + public static async Task SaveAsCsvAsync( + string path, + IAsyncEnumerable rows, + MiniExcelRustCsvWriteOptions? configuration = null, + CancellationToken cancellationToken = default) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + cancellationToken.ThrowIfCancellationRequested(); + configuration ??= new MiniExcelRustCsvWriteOptions(); + var spoolPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-csv-spool-{Guid.NewGuid():N}.bin"); + try + { + List? schema = null; + using (var spool = new FileStream( + spoolPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 81920, + useAsync: true)) + { + await foreach (var value in rows.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + var row = value is IDictionary dynamicRow + ? dynamicRow + : MiniExcelRustMapper.ToRows(new[] { value }).Single(); + schema ??= row.Keys.ToList(); + var frame = EncodeRows(new[] { row }); + var length = BitConverter.GetBytes(checked((uint)frame.Length)); + await spool.WriteAsync(length, 0, length.Length, cancellationToken).ConfigureAwait(false); + await spool.WriteAsync(frame, 0, frame.Length, cancellationToken).ConfigureAwait(false); + } + } + if (schema is null) + throw new InvalidOperationException("Async CSV export requires at least one row to infer its schema."); + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + schema, + delimiter = (byte)configuration.Delimiter, + encoding = (byte)configuration.Encoding, + configuration.WriteBom, + configuration.PrintHeader, + configuration.OverwriteFile + }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSpoolPath = new Utf8String(spoolPath); + using var nativeCancellation = NativeCancellationHandle.Create(); + using var registration = cancellationToken.Register( + static state => NativeMethods.Cancel((NativeCancellationHandle)state!), + nativeCancellation); + var payloadHandle = GCHandle.Alloc(payload, GCHandleType.Pinned); + try + { + var nativeResult = await Task.Run(() => + { + var result = NativeMethods.SaveCsvSpooledAsync( + nativePath.Pointer, + nativeSpoolPath.Pointer, + payloadHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)payload.Length, + nativeCancellation, + out var rowCount); + return (Result: result, RowCount: rowCount); + }).ConfigureAwait(false); + if (nativeResult.Result < 0) + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + throw CreateNativeException(nativeResult.Result); + } + return checked((int)nativeResult.RowCount); + } + finally + { + payloadHandle.Free(); + } + } + finally + { + DeleteTemporaryFile(spoolPath); + } + } + + /// + /// Appends dynamic rows to a CSV file without repeating its header. + /// + public static int AppendCsv( + string path, + IEnumerable> rows, + MiniExcelRustCsvWriteOptions? configuration = null) + { + return WriteCsv(path, rows, configuration, append: true); + } + + public static int AppendCsv( + string path, + IEnumerable rows, + MiniExcelRustCsvWriteOptions? configuration = null) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + return AppendCsv(path, MiniExcelRustMapper.ToRows(rows), configuration); + } + + public static int AppendCsv( + Stream stream, + IEnumerable rows, + MiniExcelRustCsvWriteOptions? configuration = null, + bool leaveOpen = false) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (rows is IEnumerable> dynamicRows) + return AppendCsv(stream, dynamicRows, configuration, leaveOpen); + return AppendCsv(stream, MiniExcelRustMapper.ToRows(rows), configuration, leaveOpen); + } + + public static int SaveAsCsv( + Stream stream, + IEnumerable> rows, + MiniExcelRustCsvWriteOptions? configuration = null, + bool leaveOpen = false) + { + ValidateWritableStream(stream); + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.csv"); + try + { + configuration ??= new MiniExcelRustCsvWriteOptions(); + var rowCount = SaveAsCsv( + temporaryPath, + rows, + new MiniExcelRustCsvWriteOptions + { + Delimiter = configuration.Delimiter, + Encoding = configuration.Encoding, + WriteBom = configuration.WriteBom, + PrintHeader = configuration.PrintHeader, + OverwriteFile = false + }); + CopyFileToStream(temporaryPath, stream); + return rowCount; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static int AppendCsv( + Stream stream, + IEnumerable> rows, + MiniExcelRustCsvWriteOptions? configuration = null, + bool leaveOpen = false) + { + ValidateReadableStream(stream); + ValidateWritableStream(stream); + if (!stream.CanSeek) + throw new ArgumentException("The stream must be seekable for CSV append.", nameof(stream)); + + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.csv"); + try + { + stream.Position = 0; + using (var output = File.Create(temporaryPath)) + stream.CopyTo(output); + var rowCount = AppendCsv(temporaryPath, rows, configuration); + CopyFileToStream(temporaryPath, stream); + return rowCount; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static void ConvertCsvToXlsx( + string csvPath, + string xlsxPath, + bool csvHasHeader = false) + { + var rows = QueryCsv(csvPath, csvHasHeader); + SaveAs(xlsxPath, rows, csvHasHeader); + } + + public static void ConvertCsvToXlsx( + Stream csvStream, + Stream xlsxStream, + bool csvHasHeader = false) + { + var rows = QueryCsv(csvStream, csvHasHeader, leaveOpen: true); + SaveAs(xlsxStream, rows, csvHasHeader, leaveOpen: true); + } + + public static Task ConvertCsvToXlsxAsync( + string csvPath, + string xlsxPath, + bool csvHasHeader = false, + CancellationToken cancellationToken = default) + { + return Task.Run(() => ConvertCsvToXlsx(csvPath, xlsxPath, csvHasHeader), cancellationToken); + } + + public static void ConvertXlsxToCsv( + string xlsxPath, + string csvPath, + bool xlsxHasHeader = true) + { + var rows = Query(xlsxPath, xlsxHasHeader); + SaveAsCsv( + csvPath, + rows, + new MiniExcelRustCsvWriteOptions { PrintHeader = xlsxHasHeader }); + } + + public static void ConvertXlsxToCsv( + Stream xlsxStream, + Stream csvStream, + bool xlsxHasHeader = true) + { + var rows = Query(xlsxStream, xlsxHasHeader, leaveOpen: true); + SaveAsCsv( + csvStream, + rows, + new MiniExcelRustCsvWriteOptions { PrintHeader = xlsxHasHeader }, + leaveOpen: true); + } + + public static Task ConvertXlsxToCsvAsync( + string xlsxPath, + string csvPath, + bool xlsxHasHeader = true, + CancellationToken cancellationToken = default) + { + return Task.Run(() => ConvertXlsxToCsv(xlsxPath, csvPath, xlsxHasHeader), cancellationToken); + } + + public static void RenameSheet(string path, string sheetName, string newSheetName) + { + ValidatePathAndSheet(path, sheetName); + if (string.IsNullOrWhiteSpace(newSheetName)) + throw new ArgumentException("The new sheet name is required.", nameof(newSheetName)); + EnsureAbiVersion(); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + using var nativeNewSheetName = new Utf8String(newSheetName); + var result = NativeMethods.RenameSheet(nativePath.Pointer, nativeSheetName.Pointer, nativeNewSheetName.Pointer); + if (result < 0) + throw CreateNativeException(result); + } + + public static void ReorderSheet(string path, string sheetName, int newSheetIndex) + { + ValidatePathAndSheet(path, sheetName); + EnsureAbiVersion(); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + var result = NativeMethods.ReorderSheet(nativePath.Pointer, nativeSheetName.Pointer, newSheetIndex); + if (result < 0) + throw CreateNativeException(result); + } + + public static void SetSheetVisibility( + string path, + string sheetName, + MiniExcelRustSheetState visibility) + { + ValidatePathAndSheet(path, sheetName); + if (visibility is < MiniExcelRustSheetState.Visible or > MiniExcelRustSheetState.VeryHidden) + throw new ArgumentOutOfRangeException(nameof(visibility)); + EnsureAbiVersion(); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + var result = NativeMethods.SetSheetVisibility(nativePath.Pointer, nativeSheetName.Pointer, (byte)visibility); + if (result < 0) + throw CreateNativeException(result); + } + + public static int InsertSheet( + string path, + IEnumerable> rows, + string sheetName, + MiniExcelRustInsertOptions? options = null) + { + ValidatePathAndSheet(path, sheetName); + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + options ??= new MiniExcelRustInsertOptions(); + EnsureAbiVersion(); + + var frame = EncodeRows(rows); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + var frameHandle = GCHandle.Alloc(frame, GCHandleType.Pinned); + try + { + var result = NativeMethods.InsertSheet( + nativePath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + nativeSheetName.Pointer, + options.PrintHeader ? (byte)1 : (byte)0, + options.ReplaceExistingSheet ? (byte)1 : (byte)0, + options.RemoveSupportedRelationships ? (byte)1 : (byte)0, + out var rowCount); + if (result < 0) + throw CreateNativeException(result); + return checked((int)rowCount); + } + finally + { + frameHandle.Free(); + } + } + + public static int InsertSheet( + Stream stream, + IEnumerable> rows, + string sheetName, + MiniExcelRustInsertOptions? options = null, + bool leaveOpen = false) + { + ValidateReadableStream(stream); + ValidateWritableStream(stream); + if (!stream.CanSeek) + throw new ArgumentException("The stream must be seekable for worksheet insertion.", nameof(stream)); + stream.Position = 0; + var temporaryPath = StageStream(stream); + try + { + var count = InsertSheet(temporaryPath, rows, sheetName, options); + CopyFileToStream(temporaryPath, stream); + return count; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static int CopyAndAddSheet( + string sourcePath, + string destinationPath, + IEnumerable> rows, + string sheetName, + MiniExcelRustInsertOptions? options = null) + { + ValidatePathAndSheet(sourcePath, sheetName); + if (string.IsNullOrWhiteSpace(destinationPath)) + throw new ArgumentException("The destination path is required.", nameof(destinationPath)); + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + options ??= new MiniExcelRustInsertOptions(); + EnsureAbiVersion(); + + var frame = EncodeRows(rows); + using var nativeSourcePath = new Utf8String(Path.GetFullPath(sourcePath)); + using var nativeDestinationPath = new Utf8String(Path.GetFullPath(destinationPath)); + using var nativeSheetName = new Utf8String(sheetName); + var frameHandle = GCHandle.Alloc(frame, GCHandleType.Pinned); + try + { + var result = NativeMethods.CopyAndAddSheet( + nativeSourcePath.Pointer, + nativeDestinationPath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + nativeSheetName.Pointer, + options.PrintHeader ? (byte)1 : (byte)0, + options.ReplaceExistingSheet ? (byte)1 : (byte)0, + options.RemoveSupportedRelationships ? (byte)1 : (byte)0, + options.OverwriteDestination ? (byte)1 : (byte)0, + out var rowCount); + if (result < 0) + throw CreateNativeException(result); + return checked((int)rowCount); + } + finally + { + frameHandle.Free(); + } + } + + public static void FillTemplate( + string destinationPath, + string templatePath, + object value, + bool overwriteFile = false, + bool ignoreMissingVariables = true) + { + if (string.IsNullOrWhiteSpace(destinationPath)) + throw new ArgumentException("The destination path is required.", nameof(destinationPath)); + if (string.IsNullOrWhiteSpace(templatePath)) + throw new ArgumentException("The template path is required.", nameof(templatePath)); + if (value is null) + throw new ArgumentNullException(nameof(value)); + + EnsureAbiVersion(); + var json = JsonSerializer.SerializeToUtf8Bytes(value, value.GetType()); + using var nativeDestinationPath = new Utf8String(Path.GetFullPath(destinationPath)); + using var nativeTemplatePath = new Utf8String(Path.GetFullPath(templatePath)); + var jsonHandle = GCHandle.Alloc(json, GCHandleType.Pinned); + try + { + var result = NativeMethods.FillTemplate( + nativeDestinationPath.Pointer, + nativeTemplatePath.Pointer, + jsonHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)json.Length, + overwriteFile ? (byte)1 : (byte)0, + ignoreMissingVariables ? (byte)1 : (byte)0); + if (result < 0) + throw CreateNativeException(result); + } + finally + { + jsonHandle.Free(); + } + } + + internal static void FillMappedTemplateCore( + string destinationPath, + string templatePath, + byte[] payload, + bool overwriteFile) + { + if (string.IsNullOrWhiteSpace(destinationPath)) + throw new ArgumentException("The destination path is required.", nameof(destinationPath)); + if (string.IsNullOrWhiteSpace(templatePath)) + throw new ArgumentException("The template path is required.", nameof(templatePath)); + if (payload is null) + throw new ArgumentNullException(nameof(payload)); + + EnsureAbiVersion(); + using var nativeDestinationPath = new Utf8String(Path.GetFullPath(destinationPath)); + using var nativeTemplatePath = new Utf8String(Path.GetFullPath(templatePath)); + var payloadHandle = GCHandle.Alloc(payload, GCHandleType.Pinned); + try + { + var result = NativeMethods.FillMappedTemplate( + nativeDestinationPath.Pointer, + nativeTemplatePath.Pointer, + payloadHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)payload.Length, + overwriteFile ? (byte)1 : (byte)0); + if (result < 0) + throw CreateNativeException(result); + } + finally + { + payloadHandle.Free(); + } + } + + public static void FillTemplate( + string destinationPath, + Stream templateStream, + object value, + bool overwriteFile = false, + bool ignoreMissingVariables = true, + bool leaveTemplateOpen = false) + { + _ = UseStagedStream(templateStream, leaveTemplateOpen, templatePath => + { + FillTemplate(destinationPath, templatePath, value, overwriteFile, ignoreMissingVariables); + return 0; + }); + } + + public static void FillTemplate( + string destinationPath, + byte[] templateBytes, + object value, + bool overwriteFile = false, + bool ignoreMissingVariables = true) + { + if (templateBytes is null) + throw new ArgumentNullException(nameof(templateBytes)); + using var templateStream = new MemoryStream(templateBytes, writable: false); + FillTemplate( + destinationPath, + templateStream, + value, + overwriteFile, + ignoreMissingVariables, + leaveTemplateOpen: false); + } + + public static void FillTemplate( + Stream destinationStream, + string templatePath, + object value, + bool ignoreMissingVariables = true, + bool leaveOpen = false) + { + ValidateWritableStream(destinationStream); + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + try + { + FillTemplate(temporaryPath, templatePath, value, false, ignoreMissingVariables); + CopyFileToStream(temporaryPath, destinationStream); + } + finally + { + if (!leaveOpen) + destinationStream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static void FillTemplate( + Stream destinationStream, + Stream templateStream, + object value, + bool ignoreMissingVariables = true, + bool leaveOpen = false, + bool leaveTemplateOpen = false) + { + _ = UseStagedStream(templateStream, leaveTemplateOpen, templatePath => + { + FillTemplate(destinationStream, templatePath, value, ignoreMissingVariables, leaveOpen); + return 0; + }); + } + + public static void FillTemplate( + Stream destinationStream, + byte[] templateBytes, + object value, + bool ignoreMissingVariables = true, + bool leaveOpen = false) + { + if (templateBytes is null) + throw new ArgumentNullException(nameof(templateBytes)); + using var templateStream = new MemoryStream(templateBytes, writable: false); + FillTemplate( + destinationStream, + templateStream, + value, + ignoreMissingVariables, + leaveOpen, + leaveTemplateOpen: false); + } + + public static void MergeSameCells( + string destinationPath, + string sourcePath, + bool overwriteFile = false) + { + if (string.IsNullOrWhiteSpace(destinationPath)) + throw new ArgumentException("The destination path is required.", nameof(destinationPath)); + if (string.IsNullOrWhiteSpace(sourcePath)) + throw new ArgumentException("The source path is required.", nameof(sourcePath)); + + EnsureAbiVersion(); + using var nativeDestinationPath = new Utf8String(Path.GetFullPath(destinationPath)); + using var nativeSourcePath = new Utf8String(Path.GetFullPath(sourcePath)); + var result = NativeMethods.MergeSameCells( + nativeDestinationPath.Pointer, + nativeSourcePath.Pointer, + overwriteFile ? (byte)1 : (byte)0); + if (result < 0) + throw CreateNativeException(result); + } + + public static void MergeSameCells( + string destinationPath, + Stream sourceStream, + bool overwriteFile = false, + bool leaveSourceOpen = false) + { + _ = UseStagedStream(sourceStream, leaveSourceOpen, sourcePath => + { + MergeSameCells(destinationPath, sourcePath, overwriteFile); + return 0; + }); + } + + public static void MergeSameCells( + Stream destinationStream, + string sourcePath, + bool leaveOpen = false) + { + ValidateWritableStream(destinationStream); + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + try + { + MergeSameCells(temporaryPath, sourcePath); + CopyFileToStream(temporaryPath, destinationStream); + } + finally + { + if (!leaveOpen) + destinationStream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static void MergeSameCells( + Stream destinationStream, + byte[] sourceBytes, + bool leaveOpen = false) + { + if (sourceBytes is null) + throw new ArgumentNullException(nameof(sourceBytes)); + using var sourceStream = new MemoryStream(sourceBytes, writable: false); + _ = UseStagedStream(sourceStream, false, sourcePath => + { + MergeSameCells(destinationStream, sourcePath, leaveOpen); + return 0; + }); + } + + public static void MergeSameCells( + Stream destinationStream, + Stream sourceStream, + bool leaveOpen = false, + bool leaveSourceOpen = false) + { + _ = UseStagedStream(sourceStream, leaveSourceOpen, sourcePath => + { + MergeSameCells(destinationStream, sourcePath, leaveOpen); + return 0; + }); + } + + public static void AddPicture(string path, params MiniExcelRustPicture[] pictures) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (pictures is null || pictures.Length == 0) + throw new ArgumentException("At least one picture is required.", nameof(pictures)); + EnsureAbiVersion(); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + foreach (var picture in pictures) + { + if (picture.ImageBytes is null || picture.ImageBytes.Length == 0) + throw new ArgumentException("Picture data is required.", nameof(pictures)); + if (picture.WidthPx <= 0 || picture.HeightPx <= 0) + throw new ArgumentOutOfRangeException(nameof(pictures), "Picture dimensions must be positive."); + using var nativeSheetName = new Utf8String(picture.SheetName); + using var nativeCellAddress = new Utf8String(picture.CellAddress); + var imageHandle = GCHandle.Alloc(picture.ImageBytes, GCHandleType.Pinned); + try + { + var result = NativeMethods.AddPicture( + nativePath.Pointer, + nativeSheetName.Pointer, + nativeCellAddress.Pointer, + imageHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)picture.ImageBytes.Length, + checked((uint)picture.WidthPx), + checked((uint)picture.HeightPx), + (byte)picture.Anchor, + picture.LocationX, + picture.LocationY); + if (result < 0) + throw CreateNativeException(result); + } + finally + { + imageHandle.Free(); + } + } + } + + public static void AddPicture( + Stream stream, + bool leaveOpen = false, + params MiniExcelRustPicture[] pictures) + { + ValidateReadableStream(stream); + ValidateWritableStream(stream); + if (!stream.CanSeek) + throw new ArgumentException("The stream must be seekable for picture insertion.", nameof(stream)); + stream.Position = 0; + var temporaryPath = StageStream(stream); + try + { + AddPicture(temporaryPath, pictures); + CopyFileToStream(temporaryPath, stream); + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + private static IEnumerable> QueryStreamIterator( + Stream stream, + bool useHeaderRow, + string? sheetName, + string startCell, + string? endCell, + MiniExcelRustReadOptions? configuration, + bool leaveOpen) + { + string? temporaryPath = null; + try + { + temporaryPath = StageStream(stream); + foreach (var row in QueryIterator(temporaryPath, useHeaderRow, sheetName, startCell, endCell, configuration)) + yield return row; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + private static IEnumerable> QueryTableStreamIterator( + Stream stream, + string? sheetName, + string tableName, + bool leaveOpen) + { + string? temporaryPath = null; + try + { + temporaryPath = StageStream(stream); + foreach (var row in QueryTableIterator(temporaryPath, sheetName, tableName)) + yield return row; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + private static IEnumerable> QueryTableIterator( + string path, + string? sheetName, + string tableName) + { + EnsureAbiVersion(); + + using var nativePath = new Utf8String(path); + using var nativeSheetName = new Utf8String(sheetName); + using var nativeTableName = new Utf8String(tableName); + var result = NativeMethods.QueryTableOpen( + nativePath.Pointer, + nativeSheetName.Pointer, + nativeTableName.Pointer, + out var rawHandle); + if (result < 0) + throw CreateNativeException(result); + + foreach (var row in ReadRows(rawHandle)) + yield return row; + } + + private static IEnumerable> QueryCsvStreamIterator( + Stream stream, + bool useHeaderRow, + MiniExcelRustCsvReadOptions? configuration, + bool leaveOpen) + { + string? temporaryPath = null; + try + { + temporaryPath = StageStream(stream); + foreach (var row in QueryCsvIterator(temporaryPath, useHeaderRow, configuration)) + yield return row; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + private static IEnumerable> QueryCsvIterator( + string path, + bool useHeaderRow, + MiniExcelRustCsvReadOptions? configuration) + { + EnsureAbiVersion(); + configuration ??= new MiniExcelRustCsvReadOptions(); + + using var nativePath = new Utf8String(path); + var result = NativeMethods.QueryCsvOpen( + nativePath.Pointer, + useHeaderRow ? (byte)1 : (byte)0, + (byte)configuration.Delimiter, + (byte)configuration.Encoding, + configuration.ReadEmptyStringAsNull ? (byte)1 : (byte)0, + configuration.TrimColumnNames ? (byte)1 : (byte)0, + out var rawHandle); + if (result < 0) + throw CreateNativeException(result); + + foreach (var row in ReadRows(rawHandle)) + yield return row; + } + + private static IEnumerable> QueryIterator( + string path, + bool useHeaderRow, + string? sheetName, + string startCell, + string? endCell, + MiniExcelRustReadOptions? configuration) + { + EnsureAbiVersion(); + + using var nativePath = new Utf8String(path); + using var nativeSheetName = new Utf8String(sheetName); + using var nativeStartCell = new Utf8String(startCell); + using var nativeEndCell = new Utf8String(endCell); + using var nativeCachePath = new Utf8String(configuration?.SharedStringCachePath); + int result; + IntPtr rawHandle; + if (configuration is not null) + { + result = NativeMethods.QueryOptionsOpen( + nativePath.Pointer, + useHeaderRow ? (byte)1 : (byte)0, + nativeSheetName.Pointer, + nativeStartCell.Pointer, + nativeEndCell.Pointer, + configuration.IgnoreEmptyRows ? (byte)1 : (byte)0, + configuration.FillMergedCells ? (byte)1 : (byte)0, + configuration.TrimColumnNames ? (byte)1 : (byte)0, + configuration.EnableSharedStringCache ? (byte)1 : (byte)0, + configuration.SharedStringCacheSize, + nativeCachePath.Pointer, + out rawHandle); + } + else if (endCell is not null) + { + result = NativeMethods.QueryRangeOpen( + nativePath.Pointer, + useHeaderRow ? (byte)1 : (byte)0, + nativeSheetName.Pointer, + nativeStartCell.Pointer, + nativeEndCell.Pointer, + out rawHandle); + } + else + { + result = NativeMethods.QueryOpen( + nativePath.Pointer, + useHeaderRow ? (byte)1 : (byte)0, + nativeSheetName.Pointer, + nativeStartCell.Pointer, + out rawHandle); + } + if (result < 0) + throw CreateNativeException(result); + + foreach (var row in ReadRows(rawHandle)) + yield return row; + } + + private static IEnumerable> ReadRows(IntPtr rawHandle) + { using var handle = new NativeQueryHandle(rawHandle); while (true) { - result = NativeMethods.QueryNextBatch(handle, BatchSize, out var data, out var length); + var result = NativeMethods.QueryNextBatch(handle, BatchSize, out var data, out var length); if (result == 0) yield break; if (result < 0) @@ -83,6 +2310,432 @@ public static class MiniExcelRust reader.EnsureComplete(); } + private static List DecodeStrings(byte[] frame) + { + var reader = new FrameReader(frame); + var count = reader.ReadLength(); + var values = new List(count); + for (var index = 0; index < count; index++) + values.Add(reader.ReadString()); + reader.EnsureComplete(); + return values; + } + + private static List DecodeRanges(byte[] frame) + { + var reader = new FrameReader(frame); + var count = reader.ReadLength(); + var ranges = new List(count); + for (var index = 0; index < count; index++) + { + var startCell = reader.ReadString(); + var endCell = reader.ReadString(); + ranges.Add(new MiniExcelRustRange( + startCell.Length == 0 ? null : startCell, + endCell.Length == 0 ? null : endCell)); + } + reader.EnsureComplete(); + return ranges; + } + + private static List DecodeSheetInfo(byte[] frame) + { + var reader = new FrameReader(frame); + var count = reader.ReadLength(); + var sheets = new List(count); + for (var index = 0; index < count; index++) + { + sheets.Add(new MiniExcelRustSheetInfo( + reader.ReadUInt32(), + reader.ReadUInt32(), + reader.ReadString(), + (MiniExcelRustSheetType)reader.ReadByte(), + (MiniExcelRustSheetState)reader.ReadByte(), + reader.ReadByte() != 0)); + } + reader.EnsureComplete(); + return sheets; + } + + private static MiniExcelRustCommentResult DecodeComments(byte[] frame) + { + var reader = new FrameReader(frame); + var sheetName = reader.ReadString(); + var commentCount = reader.ReadLength(); + var comments = new List(commentCount); + for (var index = 0; index < commentCount; index++) + { + var id = Guid.Parse(reader.ReadString()); + var referenceCell = reader.ReadString(); + var author = ReadCommentAuthor(reader); + var createdAt = ReadCommentTimestamp(reader); + var resolved = reader.ReadByte() != 0; + var text = reader.ReadString(); + var replyCount = reader.ReadLength(); + var replies = new List(replyCount); + for (var replyIndex = 0; replyIndex < replyCount; replyIndex++) + { + replies.Add(new MiniExcelRustThreadedCommentReply( + Guid.Parse(reader.ReadString()), + Guid.Parse(reader.ReadString()), + ReadCommentAuthor(reader), + ReadCommentTimestamp(reader), + reader.ReadString())); + } + comments.Add(new MiniExcelRustThreadedComment( + id, + referenceCell, + author, + createdAt, + resolved, + text, + replies)); + } + + var noteCount = reader.ReadLength(); + var notes = new List(noteCount); + for (var index = 0; index < noteCount; index++) + { + var id = reader.ReadOptionalString(); + notes.Add(new MiniExcelRustNoteComment( + id is null ? null : Guid.Parse(id), + reader.ReadString(), + reader.ReadOptionalString() ?? string.Empty, + reader.ReadString())); + } + reader.EnsureComplete(); + return new MiniExcelRustCommentResult(sheetName, comments, notes); + } + + private static MiniExcelRustCommentAuthor? ReadCommentAuthor(FrameReader reader) + { + if (reader.ReadByte() == 0) + return null; + return new MiniExcelRustCommentAuthor( + Guid.Parse(reader.ReadString()), + reader.ReadString(), + reader.ReadOptionalString()); + } + + private static DateTime? ReadCommentTimestamp(FrameReader reader) + { + var value = reader.ReadOptionalString(); + return value is null + ? null + : DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + } + + private static DataTable CreateDataTable( + IReadOnlyList columns, + IEnumerable> rows) + { + var table = new DataTable(); + foreach (var column in columns) + table.Columns.Add(column, typeof(object)); + + foreach (var row in rows) + { + var values = new object?[columns.Count]; + for (var index = 0; index < columns.Count; index++) + values[index] = row.TryGetValue(columns[index], out var value) ? value ?? DBNull.Value : DBNull.Value; + table.Rows.Add(values); + } + + return table; + } + + private static async IAsyncEnumerable ToAsyncEnumerable( + IEnumerable values, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (var value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return value; + await Task.Yield(); + } + } + + private static byte[] EncodeRows(IEnumerable> rows) + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); + WriteRows(writer, rows); + writer.Flush(); + return stream.ToArray(); + } + + private static void ReportProgress( + IProgress? progress, + IEnumerable> rows) + { + if (progress is null) + return; + foreach (var row in rows) + { + foreach (var _ in row) + progress.Report(1); + } + } + + private static byte[] EncodeMapping(IReadOnlyDictionary mapping) + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); + writer.Write(checked((uint)mapping.Count)); + foreach (var cell in mapping) + { + if (string.IsNullOrWhiteSpace(cell.Key) || string.IsNullOrWhiteSpace(cell.Value)) + throw new ArgumentException("Mapping field names and cell addresses are required.", nameof(mapping)); + WriteFrameString(writer, cell.Key); + WriteFrameString(writer, cell.Value); + } + writer.Flush(); + return stream.ToArray(); + } + + private static byte[] EncodeSheets( + IEnumerable>>> sheets) + { + var materializedSheets = sheets.ToList(); + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); + writer.Write(checked((uint)materializedSheets.Count)); + foreach (var sheet in materializedSheets) + { + if (string.IsNullOrWhiteSpace(sheet.Key)) + throw new ArgumentException("Every sheet must have a name.", nameof(sheets)); + WriteFrameString(writer, sheet.Key); + WriteRows(writer, sheet.Value); + } + writer.Flush(); + return stream.ToArray(); + } + + private static void WriteRows( + BinaryWriter writer, + IEnumerable> rows) + { + var materializedRows = rows.ToList(); + writer.Write(checked((uint)materializedRows.Count)); + foreach (var row in materializedRows) + { + writer.Write(checked((uint)row.Count)); + foreach (var cell in row) + { + WriteFrameString(writer, cell.Key); + WriteFrameValue(writer, cell.Value); + } + } + } + + private static int WriteCsv( + string path, + IEnumerable> rows, + MiniExcelRustCsvWriteOptions? configuration, + bool append) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + configuration ??= new MiniExcelRustCsvWriteOptions(); + if (configuration.Delimiter == '\0' || configuration.Delimiter > 0x7f) + throw new ArgumentException("The CSV delimiter must be a single-byte ASCII character.", nameof(configuration)); + + EnsureAbiVersion(); + var frame = EncodeRows(rows); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + var frameHandle = GCHandle.Alloc(frame, GCHandleType.Pinned); + try + { + var result = append + ? NativeMethods.AppendCsv( + nativePath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + (byte)configuration.Delimiter, + (byte)configuration.Encoding, + configuration.WriteBom ? (byte)1 : (byte)0, + configuration.PrintHeader ? (byte)1 : (byte)0, + out var rowCount) + : NativeMethods.SaveCsv( + nativePath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + (byte)configuration.Delimiter, + (byte)configuration.Encoding, + configuration.WriteBom ? (byte)1 : (byte)0, + configuration.PrintHeader ? (byte)1 : (byte)0, + configuration.OverwriteFile ? (byte)1 : (byte)0, + out rowCount); + if (result < 0) + throw CreateNativeException(result); + return checked((int)rowCount); + } + finally + { + frameHandle.Free(); + } + } + + private static void WriteFrameString(BinaryWriter writer, string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + writer.Write(checked((uint)bytes.Length)); + writer.Write(bytes); + } + + private static void WriteFrameValue(BinaryWriter writer, object? value) + { + switch (value) + { + case null: + case DBNull: + writer.Write((byte)0); + break; + case bool boolean: + writer.Write((byte)1); + writer.Write((byte)(boolean ? 1 : 0)); + break; + case byte or sbyte or short or ushort or int or uint or long: + writer.Write((byte)2); + writer.Write(Convert.ToInt64(value, CultureInfo.InvariantCulture)); + break; + case ulong unsigned when unsigned <= long.MaxValue: + writer.Write((byte)2); + writer.Write((long)unsigned); + break; + case float or double or decimal: + writer.Write((byte)3); + writer.Write(Convert.ToDouble(value, CultureInfo.InvariantCulture)); + break; + case string text: + writer.Write((byte)4); + WriteFrameString(writer, text); + break; +#if NET8_0_OR_GREATER + case DateOnly date: + writer.Write((byte)5); + WriteFrameString(writer, date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + break; + case TimeOnly time: + writer.Write((byte)6); + WriteFrameString(writer, time.ToString("HH:mm:ss.fffffff", CultureInfo.InvariantCulture)); + break; +#endif + case DateTime dateTime: + writer.Write((byte)7); + WriteFrameString(writer, dateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff", CultureInfo.InvariantCulture)); + break; + case TimeSpan duration: + writer.Write((byte)8); + writer.Write(checked((long)duration.TotalMilliseconds)); + break; + default: + throw new NotSupportedException($"Values of type {value.GetType().FullName} are not supported by SaveAs yet."); + } + } + + private static TResult UseStagedStream( + Stream stream, + bool leaveOpen, + Func operation) + { + ValidateReadableStream(stream); + string? temporaryPath = null; + try + { + temporaryPath = StageStream(stream); + return operation(temporaryPath); + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + private static string StageStream(Stream stream) + { + var path = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + try + { + using var output = File.Create(path); + stream.CopyTo(output); + return path; + } + catch + { + DeleteTemporaryFile(path); + throw; + } + } + + private static void ValidateReadableStream(Stream stream) + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + if (!stream.CanRead) + throw new ArgumentException("The stream must be readable.", nameof(stream)); + } + + private static void ValidateWritableStream(Stream stream) + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + if (!stream.CanWrite) + throw new ArgumentException("The stream must be writable.", nameof(stream)); + } + + private static void CopyFileToStream(string path, Stream destination) + { + if (destination.CanSeek) + { + destination.Position = 0; + destination.SetLength(0); + } + using var input = File.OpenRead(path); + input.CopyTo(destination); + } + + private static void ValidateCsvConfiguration(MiniExcelRustCsvReadOptions? configuration) + { + if (configuration is not null && (configuration.Delimiter == '\0' || configuration.Delimiter > 0x7f)) + throw new ArgumentException("The CSV delimiter must be a single-byte ASCII character.", nameof(configuration)); + } + + private static void ValidatePathAndSheet(string path, string sheetName) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (string.IsNullOrWhiteSpace(sheetName)) + throw new ArgumentException("The sheet name is required.", nameof(sheetName)); + } + + private static string ToCellReference(int row, int column) + { + if (row is < 1 or > 1_048_576) + throw new ArgumentOutOfRangeException(nameof(row)); + if (column is < 1 or > 16_384) + throw new ArgumentOutOfRangeException(nameof(column)); + var letters = string.Empty; + while (column > 0) + { + column--; + letters = (char)('A' + column % 26) + letters; + column /= 26; + } + return letters + row.ToString(CultureInfo.InvariantCulture); + } + + private static void DeleteTemporaryFile(string? path) + { + if (path is not null && File.Exists(path)) + File.Delete(path); + } + private static void EnsureAbiVersion() { var version = NativeMethods.GetAbiVersion(); @@ -123,6 +2776,11 @@ public string ReadString() return value; } + public string? ReadOptionalString() + { + return ReadByte() == 0 ? null : ReadString(); + } + public object? ReadValue() { EnsureAvailable(1); @@ -154,7 +2812,13 @@ private bool ReadBoolean() return frame[_offset++] != 0; } - private uint ReadUInt32() + public byte ReadByte() + { + EnsureAvailable(1); + return frame[_offset++]; + } + + public uint ReadUInt32() { EnsureAvailable(sizeof(uint)); var value = (uint)(frame[_offset] @@ -223,6 +2887,43 @@ protected override bool ReleaseHandle() } } + private sealed class NativeBufferHandle : SafeHandleZeroOrMinusOneIsInvalid + { + public NativeBufferHandle() : base(true) { } + + public NativeBufferHandle(IntPtr value) : this() + { + SetHandle(value); + } + + protected override bool ReleaseHandle() + { + NativeMethods.BufferClose(handle); + return true; + } + } + + private sealed class NativeCancellationHandle : SafeHandleZeroOrMinusOneIsInvalid + { + private NativeCancellationHandle() : base(true) { } + + public static NativeCancellationHandle Create() + { + var result = NativeMethods.CreateCancellation(out var rawHandle); + if (result < 0) + throw CreateNativeException(result); + var handle = new NativeCancellationHandle(); + handle.SetHandle(rawHandle); + return handle; + } + + protected override bool ReleaseHandle() + { + NativeMethods.CloseCancellation(handle); + return true; + } + } + private static class NativeMethods { private const string LibraryName = "miniexcel_ffi"; @@ -238,6 +2939,59 @@ internal static extern int QueryOpen( IntPtr startCell, out IntPtr handle); + [DllImport(LibraryName, EntryPoint = "miniexcel_query_range_open", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int QueryRangeOpen( + IntPtr path, + byte useHeaderRow, + IntPtr sheetName, + IntPtr startCell, + IntPtr endCell, + out IntPtr handle); + + [DllImport(LibraryName, EntryPoint = "miniexcel_query_options_open", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int QueryOptionsOpen( + IntPtr path, + byte useHeaderRow, + IntPtr sheetName, + IntPtr startCell, + IntPtr endCell, + byte ignoreEmptyRows, + byte fillMergedCells, + byte trimHeaders, + byte enableSharedStringCache, + ulong sharedStringCacheSize, + IntPtr sharedStringCachePath, + out IntPtr handle); + + [DllImport(LibraryName, EntryPoint = "miniexcel_query_table_open", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int QueryTableOpen( + IntPtr path, + IntPtr sheetName, + IntPtr tableName, + out IntPtr handle); + + [DllImport(LibraryName, EntryPoint = "miniexcel_query_csv_open", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int QueryCsvOpen( + IntPtr path, + byte useHeaderRow, + byte delimiter, + byte encoding, + byte readEmptyAsNull, + byte trimHeaders, + out IntPtr handle); + + [DllImport(LibraryName, EntryPoint = "miniexcel_get_csv_columns", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int GetCsvColumns( + IntPtr path, + byte useHeaderRow, + byte delimiter, + byte encoding, + byte readEmptyAsNull, + byte trimHeaders, + out IntPtr handle, + out IntPtr data, + out UIntPtr length); + [DllImport(LibraryName, EntryPoint = "miniexcel_query_next_batch", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] internal static extern int QueryNextBatch( NativeQueryHandle handle, @@ -248,6 +3002,207 @@ internal static extern int QueryNextBatch( [DllImport(LibraryName, EntryPoint = "miniexcel_query_close", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] internal static extern void QueryClose(IntPtr handle); + [DllImport(LibraryName, EntryPoint = "miniexcel_get_sheet_names", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int GetSheetNames( + IntPtr path, + out IntPtr handle, + out IntPtr data, + out UIntPtr length); + + [DllImport(LibraryName, EntryPoint = "miniexcel_get_columns", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int GetColumns( + IntPtr path, + byte useHeaderRow, + IntPtr sheetName, + IntPtr startCell, + out IntPtr handle, + out IntPtr data, + out UIntPtr length); + + [DllImport(LibraryName, EntryPoint = "miniexcel_get_sheet_dimensions", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int GetSheetDimensions( + IntPtr path, + out IntPtr handle, + out IntPtr data, + out UIntPtr length); + + [DllImport(LibraryName, EntryPoint = "miniexcel_get_sheet_info", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int GetSheetInfo( + IntPtr path, + out IntPtr handle, + out IntPtr data, + out UIntPtr length); + + [DllImport(LibraryName, EntryPoint = "miniexcel_get_comments", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int GetComments( + IntPtr path, + IntPtr sheetName, + out IntPtr handle, + out IntPtr data, + out UIntPtr length); + + [DllImport(LibraryName, EntryPoint = "miniexcel_read_mapped", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int ReadMapped( + IntPtr path, + IntPtr sheetName, + IntPtr mappingData, + UIntPtr mappingLength, + out IntPtr handle, + out IntPtr data, + out UIntPtr length); + + [DllImport(LibraryName, EntryPoint = "miniexcel_save_as", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SaveAs( + IntPtr path, + IntPtr data, + UIntPtr dataLength, + byte printHeader, + IntPtr sheetName, + byte overwriteFile, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_save_as_sheets", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SaveAsSheets( + IntPtr path, + IntPtr data, + UIntPtr dataLength, + byte printHeader, + byte overwriteFile, + out IntPtr handle, + out IntPtr resultData, + out UIntPtr resultLength); + + [DllImport(LibraryName, EntryPoint = "miniexcel_save_as_configured", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SaveAsConfigured( + IntPtr path, + IntPtr data, + UIntPtr dataLength, + IntPtr optionsJson, + UIntPtr optionsLength, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_save_csv", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SaveCsv( + IntPtr path, + IntPtr data, + UIntPtr dataLength, + byte delimiter, + byte encoding, + byte writeBom, + byte printHeader, + byte overwriteFile, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_append_csv", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int AppendCsv( + IntPtr path, + IntPtr data, + UIntPtr dataLength, + byte delimiter, + byte encoding, + byte writeBom, + byte printHeader, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_rename_sheet", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int RenameSheet(IntPtr path, IntPtr sheetName, IntPtr newSheetName); + + [DllImport(LibraryName, EntryPoint = "miniexcel_reorder_sheet", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int ReorderSheet(IntPtr path, IntPtr sheetName, int newSheetIndex); + + [DllImport(LibraryName, EntryPoint = "miniexcel_set_sheet_visibility", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SetSheetVisibility(IntPtr path, IntPtr sheetName, byte visibility); + + [DllImport(LibraryName, EntryPoint = "miniexcel_insert_sheet", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int InsertSheet( + IntPtr path, + IntPtr data, + UIntPtr dataLength, + IntPtr sheetName, + byte printHeader, + byte replaceExisting, + byte removeSupportedRelationships, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_copy_and_add_sheet", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int CopyAndAddSheet( + IntPtr sourcePath, + IntPtr destinationPath, + IntPtr data, + UIntPtr dataLength, + IntPtr sheetName, + byte printHeader, + byte replaceExisting, + byte removeSupportedRelationships, + byte overwriteDestination, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_fill_template", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int FillTemplate( + IntPtr destinationPath, + IntPtr templatePath, + IntPtr jsonData, + UIntPtr jsonLength, + byte overwriteFile, + byte ignoreMissingVariables); + + [DllImport(LibraryName, EntryPoint = "miniexcel_fill_mapped_template", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int FillMappedTemplate( + IntPtr destinationPath, + IntPtr templatePath, + IntPtr jsonData, + UIntPtr jsonLength, + byte overwriteFile); + + [DllImport(LibraryName, EntryPoint = "miniexcel_merge_same_cells", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int MergeSameCells( + IntPtr destinationPath, + IntPtr sourcePath, + byte overwriteFile); + + [DllImport(LibraryName, EntryPoint = "miniexcel_add_picture", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int AddPicture( + IntPtr path, + IntPtr sheetName, + IntPtr cellAddress, + IntPtr imageData, + UIntPtr imageLength, + uint widthPx, + uint heightPx, + byte anchorType, + int locationX, + int locationY); + + [DllImport(LibraryName, EntryPoint = "miniexcel_save_as_spooled_async", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SaveAsSpooledAsync( + IntPtr path, + IntPtr spoolPath, + IntPtr optionsJson, + UIntPtr optionsLength, + NativeCancellationHandle cancellation, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_save_csv_spooled_async", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SaveCsvSpooledAsync( + IntPtr path, + IntPtr spoolPath, + IntPtr optionsJson, + UIntPtr optionsLength, + NativeCancellationHandle cancellation, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_cancellation_create", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int CreateCancellation(out IntPtr handle); + + [DllImport(LibraryName, EntryPoint = "miniexcel_cancellation_cancel", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void Cancel(NativeCancellationHandle handle); + + [DllImport(LibraryName, EntryPoint = "miniexcel_cancellation_close", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void CloseCancellation(IntPtr handle); + + [DllImport(LibraryName, EntryPoint = "miniexcel_buffer_close", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void BufferClose(IntPtr handle); + [DllImport(LibraryName, EntryPoint = "miniexcel_last_error", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] internal static extern IntPtr GetLastError(out UIntPtr length); } diff --git a/src/MiniExcelRust/MiniExcelRust.csproj b/src/MiniExcelRust/MiniExcelRust.csproj index d0fdd0f..0fb0f70 100644 --- a/src/MiniExcelRust/MiniExcelRust.csproj +++ b/src/MiniExcelRust/MiniExcelRust.csproj @@ -27,6 +27,10 @@ true + + + + diff --git a/src/MiniExcelRust/MiniExcelRustComments.cs b/src/MiniExcelRust/MiniExcelRustComments.cs new file mode 100644 index 0000000..8842fc5 --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustComments.cs @@ -0,0 +1,102 @@ +namespace MiniExcelLibs; + +public sealed class MiniExcelRustCommentResult +{ + internal MiniExcelRustCommentResult( + string sheetName, + List comments, + List notes) + { + SheetName = sheetName; + Comments = comments; + Notes = notes; + } + + public string SheetName { get; } + + public IReadOnlyList Comments { get; } + + public IReadOnlyList Notes { get; } +} + +public sealed class MiniExcelRustThreadedComment +{ + internal MiniExcelRustThreadedComment( + Guid id, + string referenceCell, + MiniExcelRustCommentAuthor? author, + DateTime? createdAt, + bool resolved, + string text, + List replies) + { + Id = id; + ReferenceCell = referenceCell; + Author = author; + CreatedAt = createdAt; + Resolved = resolved; + Text = text; + Replies = replies; + } + + public Guid Id { get; } + public string ReferenceCell { get; } + public MiniExcelRustCommentAuthor? Author { get; } + public DateTime? CreatedAt { get; } + public bool Resolved { get; } + public string Text { get; } + public IReadOnlyList Replies { get; } +} + +public sealed class MiniExcelRustThreadedCommentReply +{ + internal MiniExcelRustThreadedCommentReply( + Guid id, + Guid parentId, + MiniExcelRustCommentAuthor? author, + DateTime? createdAt, + string text) + { + Id = id; + ParentId = parentId; + Author = author; + CreatedAt = createdAt; + Text = text; + } + + public Guid Id { get; } + public Guid ParentId { get; } + public MiniExcelRustCommentAuthor? Author { get; } + public DateTime? CreatedAt { get; } + public string Text { get; } +} + +public sealed class MiniExcelRustNoteComment +{ + internal MiniExcelRustNoteComment(Guid? id, string referenceCell, string? author, string text) + { + Id = id; + ReferenceCell = referenceCell; + Author = author; + Text = text; + } + + public Guid? Id { get; } + public string ReferenceCell { get; } + public string? Author { get; } + public string Text { get; } +} + +public sealed class MiniExcelRustCommentAuthor +{ + internal MiniExcelRustCommentAuthor(Guid id, string displayName, string? providerId) + { + Id = id; + DisplayName = displayName; + ProviderId = providerId; + } + + public Guid Id { get; } + public string DisplayName { get; } + public string? ProviderId { get; } +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRustCsvReadOptions.cs b/src/MiniExcelRust/MiniExcelRustCsvReadOptions.cs new file mode 100644 index 0000000..b018eda --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustCsvReadOptions.cs @@ -0,0 +1,29 @@ +namespace MiniExcelLibs; + +public enum MiniExcelRustCsvEncoding : byte +{ + Utf8, + Utf16Le, + Utf16Be, + Gbk, + Windows1252 +} + +/// +/// Configures Rust-backed CSV queries. +/// +public sealed class MiniExcelRustCsvReadOptions +{ + public System.Globalization.CultureInfo Culture { get; set; } = System.Globalization.CultureInfo.InvariantCulture; + + public IDictionary DynamicColumns { get; } = + new Dictionary(StringComparer.Ordinal); + + public char Delimiter { get; set; } = ','; + + public MiniExcelRustCsvEncoding Encoding { get; set; } = MiniExcelRustCsvEncoding.Utf8; + + public bool ReadEmptyStringAsNull { get; set; } + + public bool TrimColumnNames { get; set; } +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRustCsvWriteOptions.cs b/src/MiniExcelRust/MiniExcelRustCsvWriteOptions.cs new file mode 100644 index 0000000..868bf00 --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustCsvWriteOptions.cs @@ -0,0 +1,17 @@ +namespace MiniExcelLibs; + +/// +/// Configures Rust-backed CSV writes. +/// +public sealed class MiniExcelRustCsvWriteOptions +{ + public char Delimiter { get; set; } = ','; + + public MiniExcelRustCsvEncoding Encoding { get; set; } = MiniExcelRustCsvEncoding.Utf8; + + public bool WriteBom { get; set; } = true; + + public bool PrintHeader { get; set; } = true; + + public bool OverwriteFile { get; set; } +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRustDynamicColumn.cs b/src/MiniExcelRust/MiniExcelRustDynamicColumn.cs new file mode 100644 index 0000000..4f2512b --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustDynamicColumn.cs @@ -0,0 +1,11 @@ +namespace MiniExcelLibs; + +public sealed class MiniExcelRustDynamicColumn +{ + public string? Name { get; set; } + public int? Index { get; set; } + public string? Format { get; set; } + public bool Ignore { get; set; } + public Func? CustomFormatter { get; set; } + public bool IsFormula { get; set; } +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRustFluentMapping.cs b/src/MiniExcelRust/MiniExcelRustFluentMapping.cs new file mode 100644 index 0000000..02ecbec --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustFluentMapping.cs @@ -0,0 +1,731 @@ +using System.Collections; +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; + +namespace MiniExcelLibs; + +public sealed class MiniExcelRustMapping +{ + private readonly List _nodes = new(); + + public string WorksheetName { get; private set; } = "Sheet1"; + + public MiniExcelRustPropertyMapping Property( + Expression> property) + { + if (property is null) + throw new ArgumentNullException(nameof(property)); + var node = new PropertyNode( + source => property.Compile()((T)source), + CreateSetter(property), + typeof(TProperty)); + _nodes.Add(node); + return new MiniExcelRustPropertyMapping(node); + } + + public MiniExcelRustCollectionMapping Collection( + Expression> collection) + where TCollection : IEnumerable + { + if (collection is null) + throw new ArgumentNullException(nameof(collection)); + var node = new CollectionNode( + source => propertyValue(collection.Compile()((T)source)), + CreateSetter(collection), + typeof(TCollection), + CollectionItemType(typeof(TCollection))); + _nodes.Add(node); + return new MiniExcelRustCollectionMapping(node); + + static IEnumerable propertyValue(TCollection value) => value; + } + + public MiniExcelRustMapping ToWorksheet(string worksheetName) + { + if (string.IsNullOrWhiteSpace(worksheetName) || worksheetName.Length > 31) + throw new ArgumentException("The worksheet name must contain 1 to 31 characters.", nameof(worksheetName)); + WorksheetName = worksheetName; + return this; + } + + internal int MaximumRow => _nodes.Count == 0 ? 1 : _nodes.Max(node => node.MaximumRow); + internal int MinimumRow => _nodes.Count == 0 ? 1 : _nodes.Min(node => node.MinimumRow); + + internal int Write(object source, MappedGrid grid, int rowOffset) + { + var maximumRow = rowOffset; + foreach (var node in _nodes) + maximumRow = Math.Max(maximumRow, node.Write(source, grid, rowOffset)); + return maximumRow; + } + + internal void Read(object destination, IReadOnlyList> rows, int rowOffset, int endRow) + { + foreach (var node in _nodes) + node.Read(destination, rows, rowOffset, endRow); + } + + internal bool HasAnchorData(IReadOnlyList> rows, int rowOffset) + { + var directAnchors = _nodes + .OfType() + .Where(node => node.MinimumRow == MinimumRow) + .Cast() + .ToArray(); + var anchors = directAnchors.Length > 0 + ? directAnchors + : _nodes.Where(node => node.MinimumRow == MinimumRow); + return anchors.Any(node => node.HasData(rows, rowOffset)); + } + + private static Action? CreateSetter(Expression> expression) + { + var member = expression.Body as MemberExpression; + if (member is null && expression.Body is UnaryExpression unary) + member = unary.Operand as MemberExpression; + return member?.Member switch + { + PropertyInfo property when property.CanWrite => (target, value) => property.SetValue(target, ConvertValue(value, property.PropertyType)), + FieldInfo field when !field.IsInitOnly => (target, value) => field.SetValue(target, ConvertValue(value, field.FieldType)), + _ => null + }; + } + + private static Type CollectionItemType(Type collectionType) + { + if (collectionType.IsArray) + return collectionType.GetElementType()!; + return collectionType + .GetInterfaces() + .Concat(new[] { collectionType }) + .FirstOrDefault(type => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))? + .GetGenericArguments()[0] ?? typeof(object); + } + + internal static object? ConvertValue(object? value, Type targetType) + { + if (value is null || value is DBNull) + return targetType.IsValueType && Nullable.GetUnderlyingType(targetType) is null + ? Activator.CreateInstance(targetType) + : null; + var effectiveType = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (effectiveType.IsInstanceOfType(value)) + return value; + if (effectiveType.IsEnum) + return value is string text + ? Enum.Parse(effectiveType, text, true) + : Enum.ToObject(effectiveType, Convert.ToInt64(value, CultureInfo.InvariantCulture)); + if (effectiveType == typeof(Guid)) + return Guid.Parse(Convert.ToString(value, CultureInfo.InvariantCulture)!); + if (effectiveType == typeof(Uri)) + return new Uri(Convert.ToString(value, CultureInfo.InvariantCulture)!, UriKind.RelativeOrAbsolute); + if (effectiveType == typeof(TimeSpan)) + return value is TimeSpan span ? span : TimeSpan.Parse(Convert.ToString(value, CultureInfo.InvariantCulture)!, CultureInfo.InvariantCulture); + if (effectiveType == typeof(DateTimeOffset)) + return value is DateTimeOffset offset ? offset : new DateTimeOffset(Convert.ToDateTime(value, CultureInfo.InvariantCulture)); + return Convert.ChangeType(value, effectiveType, CultureInfo.InvariantCulture); + } + + internal interface IMappedNode + { + int MinimumRow { get; } + int MaximumRow { get; } + int Write(object source, MappedGrid grid, int rowOffset); + void Read(object destination, IReadOnlyList> rows, int rowOffset, int endRow); + bool HasData(IReadOnlyList> rows, int rowOffset); + } + + internal sealed class PropertyNode( + Func getter, + Action? setter, + Type propertyType) : IMappedNode + { + public int Row { get; set; } + public int Column { get; set; } + public string? Format { get; set; } + public string? Formula { get; set; } + public int MinimumRow => Row; + public int MaximumRow => Row; + + public int Write(object source, MappedGrid grid, int rowOffset) + { + if (Row == 0) + throw new InvalidOperationException("A property mapping requires ToCell()."); + grid.Set(Row + rowOffset, Column, Formula ?? getter(source), Format, Formula is not null); + return Row + rowOffset; + } + + public void Read(object destination, IReadOnlyList> rows, int rowOffset, int endRow) + { + if (Row == 0) + throw new InvalidOperationException("A property mapping requires ToCell()."); + if (setter is null) + throw new InvalidOperationException("A mapped property must be writable when reading."); + if (MappedGrid.TryGet(rows, Row + rowOffset, Column, out var value)) + setter(destination, ConvertValue(value, propertyType)); + } + + public bool HasData(IReadOnlyList> rows, int rowOffset) => + Row != 0 && MappedGrid.TryGet(rows, Row + rowOffset, Column, out _); + } + + internal sealed class CollectionNode( + Func getter, + Action? setter, + Type collectionType, + Type itemType) : IMappedNode + { + public int Row { get; set; } + public int Column { get; set; } + public int Spacing { get; set; } + public IItemPlan? ItemPlan { get; set; } + public int MinimumRow => Row; + public int MaximumRow => ItemPlan?.MaximumRow ?? Math.Max(1, Row); + + public int Write(object source, MappedGrid grid, int rowOffset) + { + if (Row == 0) + throw new InvalidOperationException("A collection mapping requires StartAt()."); + var maximumRow = Row + rowOffset; + var itemOffset = rowOffset; + foreach (var item in getter(source)) + { + if (ItemPlan is null) + { + grid.Set(Row + itemOffset, Column, item, null, false); + maximumRow = Row + itemOffset; + itemOffset += Spacing + 1; + } + else if (item is not null) + { + maximumRow = Math.Max(maximumRow, ItemPlan.Write(item, grid, itemOffset)); + itemOffset += Math.Max(1, maximumRow - (Row + itemOffset) + 1) + Spacing; + } + } + return maximumRow; + } + + public void Read(object destination, IReadOnlyList> rows, int rowOffset, int endRow) + { + if (Row == 0) + throw new InvalidOperationException("A collection mapping requires StartAt()."); + if (setter is null) + throw new InvalidOperationException("A mapped collection must be writable when reading."); + var values = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(itemType))!; + if (ItemPlan is null) + { + for (var row = Row + rowOffset; row <= endRow; row += Spacing + 1) + { + if (!MappedGrid.TryGet(rows, row, Column, out var value)) + break; + values.Add(ConvertValue(value, itemType)); + } + } + else + { + var starts = new List(); + for (var row = Row + rowOffset; row <= endRow; row++) + { + var itemOffset = row - ItemPlan.MinimumRow; + if (ItemPlan.HasAnchorData(rows, itemOffset)) + starts.Add(row); + } + for (var index = 0; index < starts.Count; index++) + { + var itemOffset = starts[index] - ItemPlan.MinimumRow; + var itemEnd = index + 1 < starts.Count ? starts[index + 1] - 1 : endRow; + values.Add(ItemPlan.Read(rows, itemOffset, itemEnd)); + } + } + setter(destination, AdaptCollection(values, collectionType, itemType)); + } + + public bool HasData(IReadOnlyList> rows, int rowOffset) => + Row != 0 && (ItemPlan?.HasAnchorData(rows, rowOffset + Row - ItemPlan.MinimumRow) + ?? MappedGrid.TryGet(rows, Row + rowOffset, Column, out _)); + + private static object AdaptCollection(IList values, Type targetType, Type elementType) + { + if (targetType.IsArray) + { + var array = Array.CreateInstance(elementType, values.Count); + values.CopyTo(array, 0); + return array; + } + if (targetType.IsInstanceOfType(values)) + return values; + if (Activator.CreateInstance(targetType) is IList target) + { + foreach (var value in values) + target.Add(value); + return target; + } + throw new InvalidOperationException($"Mapped collection type '{targetType}' cannot be populated."); + } + } + + internal interface IItemPlan + { + int MinimumRow { get; } + int MaximumRow { get; } + int Write(object source, MappedGrid grid, int rowOffset); + object Read(IReadOnlyList> rows, int rowOffset, int endRow); + bool HasAnchorData(IReadOnlyList> rows, int rowOffset); + } + + internal sealed class ItemPlan(MiniExcelRustMapping mapping) : IItemPlan + { + public int MinimumRow => mapping.MinimumRow; + public int MaximumRow => mapping.MaximumRow; + public int Write(object source, MappedGrid grid, int rowOffset) => mapping.Write(source, grid, rowOffset); + public object Read(IReadOnlyList> rows, int rowOffset, int endRow) + { + var item = Activator.CreateInstance()!; + mapping.Read(item!, rows, rowOffset, endRow); + return item!; + } + public bool HasAnchorData(IReadOnlyList> rows, int rowOffset) => + mapping.HasAnchorData(rows, rowOffset); + } +} + +public sealed class MiniExcelRustPropertyMapping +{ + private readonly MiniExcelRustMapping.PropertyNode _node; + + internal MiniExcelRustPropertyMapping(MiniExcelRustMapping.PropertyNode node) + { + _node = node; + } + + public MiniExcelRustPropertyMapping ToCell(string cellAddress) + { + (_node.Row, _node.Column) = MappedGrid.ParseCell(cellAddress); + return this; + } + + public MiniExcelRustPropertyMapping WithFormat(string format) + { + _node.Format = format; + return this; + } + + public MiniExcelRustPropertyMapping WithFormula(string formula) + { + _node.Formula = formula; + return this; + } +} + +public sealed class MiniExcelRustCollectionMapping + where TCollection : IEnumerable +{ + private readonly MiniExcelRustMapping.CollectionNode _node; + + internal MiniExcelRustCollectionMapping(MiniExcelRustMapping.CollectionNode node) + { + _node = node; + } + + public MiniExcelRustCollectionMapping StartAt(string cellAddress) + { + (_node.Row, _node.Column) = MappedGrid.ParseCell(cellAddress); + return this; + } + + public MiniExcelRustCollectionMapping WithSpacing(int spacing) + { + if (spacing < 0) + throw new ArgumentOutOfRangeException(nameof(spacing)); + _node.Spacing = spacing; + return this; + } + + public MiniExcelRustCollectionMapping WithItemMapping( + Action> configure) + { + if (configure is null) + throw new ArgumentNullException(nameof(configure)); + var mapping = new MiniExcelRustMapping(); + configure(mapping); + _node.ItemPlan = new MiniExcelRustMapping.ItemPlan(mapping); + return this; + } +} + +internal sealed class MappedGrid +{ + private readonly SortedDictionary<(int Row, int Column), object?> _values = new(); + private readonly Dictionary _formats = new(); + private readonly HashSet<(int Row, int Column)> _formulaCells = new(); + + public void Set(int row, int column, object? value, string? format, bool formula) + { + _values[(row, column)] = value; + if (format is not null) + _formats[column] = format; + if (formula) + _formulaCells.Add((row, column)); + } + + public int Save(string path, string sheetName, bool overwriteFile) + { + if (_values.Count == 0) + throw new InvalidOperationException("The mapping did not produce any cells."); + var maxRow = _values.Keys.Max(cell => cell.Row); + var maxColumn = _values.Keys.Max(cell => cell.Column); + var schema = Enumerable.Range(1, maxColumn).Select(ColumnName).ToArray(); + var rows = new List>(maxRow); + for (var rowIndex = 1; rowIndex <= maxRow; rowIndex++) + { + IDictionary row = new Dictionary(StringComparer.Ordinal); + foreach (var cell in _values.Where(value => value.Key.Row == rowIndex)) + row[ColumnName(cell.Key.Column)] = cell.Value; + rows.Add(row); + } + var options = new MiniExcelRustWriteOptions + { + SheetName = sheetName, + PrintHeader = false, + OverwriteFile = overwriteFile + }; + foreach (var format in _formats) + options.ColumnFormats[ColumnName(format.Key)] = format.Value; + var count = MiniExcelRust.SaveAsWithSchema(path, schema, rows, options); + if (_formulaCells.Count > 0) + MiniExcelRust.FillMappedTemplateCore(path, path, CreateTemplatePayload(sheetName), true); + return count; + } + + public byte[] CreateTemplatePayload(string sheetName) + { + return JsonSerializer.SerializeToUtf8Bytes(new + { + sheetName, + cells = _values.Select(cell => new + { + address = $"{ColumnName(cell.Key.Column)}{cell.Key.Row}", + value = cell.Value, + formula = _formulaCells.Contains(cell.Key) + }) + }); + } + + internal static (int Row, int Column) ParseCell(string cellAddress) + { + if (string.IsNullOrWhiteSpace(cellAddress)) + throw new ArgumentException("A cell address is required.", nameof(cellAddress)); + var letters = cellAddress.TakeWhile(char.IsLetter).ToArray(); + var digits = cellAddress.SkipWhile(char.IsLetter).ToArray(); + if (letters.Length == 0 || digits.Length == 0 || !int.TryParse(new string(digits), out var row) || row < 1) + throw new ArgumentException($"Invalid cell address '{cellAddress}'.", nameof(cellAddress)); + var column = 0; + foreach (var letter in letters) + { + if (letter is not (>= 'A' and <= 'Z') and not (>= 'a' and <= 'z')) + throw new ArgumentException($"Invalid cell address '{cellAddress}'.", nameof(cellAddress)); + column = checked(column * 26 + char.ToUpperInvariant(letter) - 'A' + 1); + } + if (column > 16_384 || row > 1_048_576) + throw new ArgumentOutOfRangeException(nameof(cellAddress)); + return (row, column); + } + + internal static bool TryGet( + IReadOnlyList> rows, + int row, + int column, + out object? value) + { + value = null; + return row > 0 && row <= rows.Count && + rows[row - 1].TryGetValue(ColumnName(column), out value) && + value is not null && value is not DBNull && + (value is not string text || text.Length > 0); + } + + internal static string ColumnName(int column) + { + var name = string.Empty; + while (column > 0) + { + column--; + name = (char)('A' + column % 26) + name; + column /= 26; + } + return name; + } +} + +public static partial class MiniExcelRustMappingExtensions +{ + public static T ReadMapped( + string path, + MiniExcelRustMapping mapping) + where T : new() + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (mapping is null) + throw new ArgumentNullException(nameof(mapping)); + var rows = MiniExcelRust.Query(path, false, mapping.WorksheetName).ToList(); + var value = new T(); + mapping.Read(value!, rows, 0, rows.Count); + return value; + } + + public static T ReadMapped( + Stream stream, + MiniExcelRustMapping mapping, + bool leaveOpen = false) + where T : new() + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + if (mapping is null) + throw new ArgumentNullException(nameof(mapping)); + var rows = MiniExcelRust.Query(stream, false, mapping.WorksheetName, leaveOpen: leaveOpen).ToList(); + var value = new T(); + mapping.Read(value!, rows, 0, rows.Count); + return value; + } + + public static Task ReadMappedAsync( + string path, + MiniExcelRustMapping mapping, + CancellationToken cancellationToken = default) + where T : new() + { + return Task.Run(() => ReadMapped(path, mapping), cancellationToken); + } + + public static Task ReadMappedAsync( + Stream stream, + MiniExcelRustMapping mapping, + bool leaveOpen = false, + CancellationToken cancellationToken = default) + where T : new() + { + return Task.Run(() => ReadMapped(stream, mapping, leaveOpen), cancellationToken); + } + + public static int ExportMapped( + string path, + IEnumerable values, + MiniExcelRustMapping mapping, + bool overwriteFile = false) + { + if (values is null) + throw new ArgumentNullException(nameof(values)); + if (mapping is null) + throw new ArgumentNullException(nameof(mapping)); + var grid = BuildGrid(values, mapping); + return grid.Save(path, mapping.WorksheetName, overwriteFile); + } + + public static int ExportMapped( + Stream stream, + IEnumerable values, + MiniExcelRustMapping mapping, + bool leaveOpen = false) + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + if (!stream.CanWrite) + throw new ArgumentException("The stream must be writable.", nameof(stream)); + var outputPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-mapped-export-{Guid.NewGuid():N}.xlsx"); + try + { + var count = ExportMapped(outputPath, values, mapping); + CopyToStream(outputPath, stream); + return count; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + if (File.Exists(outputPath)) + File.Delete(outputPath); + } + } + + public static Task ExportMappedAsync( + string path, + IEnumerable values, + MiniExcelRustMapping mapping, + bool overwriteFile = false, + CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + var grid = BuildGrid(values, mapping, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return grid.Save(path, mapping.WorksheetName, overwriteFile); + }, cancellationToken); + } + + public static Task ExportMappedAsync( + Stream stream, + IEnumerable values, + MiniExcelRustMapping mapping, + bool leaveOpen = false, + CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + return ExportMapped(stream, values, mapping, leaveOpen); + }, cancellationToken); + } + + public static void FillMappedTemplate( + string destinationPath, + string templatePath, + IEnumerable values, + MiniExcelRustMapping mapping, + bool overwriteFile = false) + { + if (values is null) + throw new ArgumentNullException(nameof(values)); + if (mapping is null) + throw new ArgumentNullException(nameof(mapping)); + var grid = BuildGrid(values, mapping); + MiniExcelRust.FillMappedTemplateCore( + destinationPath, + templatePath, + grid.CreateTemplatePayload(mapping.WorksheetName), + overwriteFile); + } + + public static void FillMappedTemplate( + Stream outputStream, + Stream templateStream, + IEnumerable values, + MiniExcelRustMapping mapping, + bool leaveOpen = false, + bool leaveTemplateOpen = false) + { + if (outputStream is null) + throw new ArgumentNullException(nameof(outputStream)); + if (!outputStream.CanWrite) + throw new ArgumentException("The stream must be writable.", nameof(outputStream)); + if (templateStream is null) + throw new ArgumentNullException(nameof(templateStream)); + if (!templateStream.CanRead) + throw new ArgumentException("The stream must be readable.", nameof(templateStream)); + var templatePath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-mapped-template-{Guid.NewGuid():N}.xlsx"); + var outputPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-mapped-output-{Guid.NewGuid():N}.xlsx"); + try + { + using (var file = File.Create(templatePath)) + templateStream.CopyTo(file); + FillMappedTemplate(outputPath, templatePath, values, mapping); + if (outputStream.CanSeek) + { + outputStream.Position = 0; + outputStream.SetLength(0); + } + using var result = File.OpenRead(outputPath); + result.CopyTo(outputStream); + } + finally + { + if (!leaveOpen) + outputStream.Dispose(); + if (!leaveTemplateOpen) + templateStream.Dispose(); + if (File.Exists(templatePath)) + File.Delete(templatePath); + if (File.Exists(outputPath)) + File.Delete(outputPath); + } + } + + public static void FillMappedTemplate( + Stream outputStream, + byte[] templateBytes, + IEnumerable values, + MiniExcelRustMapping mapping, + bool leaveOpen = false) + { + if (templateBytes is null) + throw new ArgumentNullException(nameof(templateBytes)); + using var templateStream = new MemoryStream(templateBytes, writable: false); + FillMappedTemplate(outputStream, templateStream, values, mapping, leaveOpen, false); + } + + public static Task FillMappedTemplateAsync( + string destinationPath, + string templatePath, + IEnumerable values, + MiniExcelRustMapping mapping, + bool overwriteFile = false, + CancellationToken cancellationToken = default) + { + return Task.Run( + () => FillMappedTemplate(destinationPath, templatePath, values, mapping, overwriteFile), + cancellationToken); + } + + public static Task FillMappedTemplateAsync( + Stream outputStream, + Stream templateStream, + IEnumerable values, + MiniExcelRustMapping mapping, + bool leaveOpen = false, + bool leaveTemplateOpen = false, + CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + FillMappedTemplate(outputStream, templateStream, values, mapping, leaveOpen, leaveTemplateOpen); + }, cancellationToken); + } + + public static Task FillMappedTemplateAsync( + Stream outputStream, + byte[] templateBytes, + IEnumerable values, + MiniExcelRustMapping mapping, + bool leaveOpen = false, + CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + FillMappedTemplate(outputStream, templateBytes, values, mapping, leaveOpen); + }, cancellationToken); + } + + private static MappedGrid BuildGrid( + IEnumerable values, + MiniExcelRustMapping mapping, + CancellationToken cancellationToken = default) + { + var grid = new MappedGrid(); + var offset = 0; + foreach (var value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + if (value is null) + throw new ArgumentException("Mapped values cannot contain null.", nameof(values)); + var maximumRow = mapping.Write(value, grid, offset); + offset = Math.Max(offset + mapping.MaximumRow, maximumRow); + } + return grid; + } + + private static void CopyToStream(string path, Stream stream) + { + if (stream.CanSeek) + { + stream.Position = 0; + stream.SetLength(0); + } + using var input = File.OpenRead(path); + input.CopyTo(stream); + } +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRustInsertOptions.cs b/src/MiniExcelRust/MiniExcelRustInsertOptions.cs new file mode 100644 index 0000000..4a95961 --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustInsertOptions.cs @@ -0,0 +1,15 @@ +namespace MiniExcelLibs; + +/// +/// Configures insertion of a Rust-generated worksheet into an XLSX workbook. +/// +public sealed class MiniExcelRustInsertOptions +{ + public bool PrintHeader { get; set; } = true; + + public bool ReplaceExistingSheet { get; set; } + + public bool RemoveSupportedRelationships { get; set; } + + public bool OverwriteDestination { get; set; } +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRustMapper.cs b/src/MiniExcelRust/MiniExcelRustMapper.cs new file mode 100644 index 0000000..16cabea --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustMapper.cs @@ -0,0 +1,421 @@ +using System.ComponentModel; +using System.Collections; +using System.Globalization; +using System.Reflection; +using System.Resources; + +namespace MiniExcelLibs; + +internal static class MiniExcelRustMapper +{ + public static IEnumerable> ToRows(IEnumerable values) + { + IReadOnlyList? mappings = null; + Type? mappedType = null; + foreach (var value in values) + { + if (value is null) + throw new ArgumentException("Typed export rows cannot contain null values.", nameof(values)); + if (value is IDictionary row) + { + yield return row; + continue; + } + var valueType = value.GetType(); + if (mappedType != valueType) + { + mappedType = valueType; + mappings = CreateMappings(valueType, true, CultureInfo.InvariantCulture, null) + .OrderBy(mapping => mapping.Index ?? int.MaxValue) + .ToList(); + } + IDictionary projected = new Dictionary(StringComparer.Ordinal); + foreach (var mapping in mappings!) + projected.Add(mapping.Names[0], NormalizeWriteValue(mapping.FormatValue(mapping.GetValue(value)))); + yield return projected; + } + } + + public static IEnumerable> ToRows( + IEnumerable values, + IReadOnlyDictionary? dynamicColumns = null) + { + var mappings = CreateMappings( + typeof(T), + forWrite: true, + CultureInfo.InvariantCulture, + dynamicColumns) + .OrderBy(mapping => mapping.Index ?? int.MaxValue) + .ToList(); + foreach (var value in values) + { + if (value is null) + throw new ArgumentException("Typed export rows cannot contain null values.", nameof(values)); + IDictionary row = new Dictionary(StringComparer.Ordinal); + foreach (var mapping in mappings) + row.Add( + mapping.Names[0], + NormalizeWriteValue(mapping.FormatValue(mapping.GetValue(value)))); + yield return row; + } + } + + public static IEnumerable Map( + IEnumerable> rows, + CultureInfo? culture = null, + IReadOnlyDictionary? dynamicColumns = null) + where T : class, new() + { + culture ??= CultureInfo.InvariantCulture; + var mappings = CreateMappings(typeof(T), forWrite: false, culture, dynamicColumns); + var rowIndex = 1; + foreach (var row in rows) + { + var instance = new T(); + var values = row.Values.ToList(); + foreach (var mapping in mappings) + { + object? value = null; + var found = mapping.Index is int index + ? index >= 0 && index < values.Count && Assign(values[index], out value) + : TryGetValue(row, mapping.Names, out value); + if (!found) + throw new MiniExcelRustColumnNotFoundException(mapping.Names[0], rowIndex); + + try + { + mapping.SetValue(instance, ConvertValue(value, mapping.ValueType, mapping.Format, culture)); + } + catch (Exception error) when (error is InvalidCastException or FormatException or OverflowException or ArgumentException) + { + throw new MiniExcelRustMappingException( + mapping.Names[0], + rowIndex, + value, + mapping.ValueType, + error); + } + } + yield return instance; + rowIndex++; + } + } + + private static IReadOnlyList CreateMappings( + Type type, + bool forWrite, + CultureInfo culture, + IReadOnlyDictionary? dynamicColumns) + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public; + var members = type.GetProperties(flags) + .Where(property => + property.GetIndexParameters().Length == 0 && + (forWrite ? property.GetMethod is not null : property.SetMethod is not null)) + .Cast() + .Concat(type.GetFields(flags).Where(HasMiniExcelAttribute)); + return members + .Where(member => !IsIgnored(member)) + .Select(member => CreateMapping(member, culture, dynamicColumns)) + .Where(mapping => !mapping.Ignore) + .ToList(); + } + + private static MemberMapping CreateMapping( + MemberInfo member, + CultureInfo culture, + IReadOnlyDictionary? dynamicColumns) + { + var names = new List { member.Name }; + int? index = null; + string? format = null; + Type? resourceType = null; + foreach (var attribute in member.CustomAttributes) + { + var name = attribute.AttributeType.Name; + if (name is "ExcelColumnNameAttribute" or "MiniExcelColumnNameAttribute") + { + AddConstructorName(attribute, names); + AddNamedString(attribute, "Name", names); + AddAliases(attribute, names); + resourceType = ReadNamedType(attribute, "ResourceType") ?? resourceType; + } + else if (name is "ExcelColumnIndexAttribute" or "MiniExcelColumnIndexAttribute") + { + index = ReadIndex(attribute); + } + else if (name is "ExcelColumnAttribute" or "MiniExcelColumnAttribute") + { + AddNamedString(attribute, "Name", names); + AddAliases(attribute, names); + index = ReadNamedInt(attribute, "Index") ?? index; + format = ReadNamedString(attribute, "Format") ?? format; + resourceType = ReadNamedType(attribute, "ResourceType") ?? resourceType; + } + else if (name is "ExcelFormatAttribute" or "MiniExcelFormatAttribute") + { + format = attribute.ConstructorArguments.FirstOrDefault().Value as string; + } + } + + if (member.GetCustomAttribute() is { DisplayName.Length: > 0 } display) + names.Insert(0, display.DisplayName); + if (resourceType is not null) + names[0] = GetLocalizedName(resourceType, names[0], culture); + + var dynamicColumn = dynamicColumns is not null && dynamicColumns.TryGetValue(member.Name, out var configured) + ? configured + : null; + if (!string.IsNullOrWhiteSpace(dynamicColumn?.Name)) + names.Insert(0, dynamicColumn!.Name!); + index = dynamicColumn?.Index ?? index; + format = dynamicColumn?.Format ?? format; + + var valueType = member is PropertyInfo property ? property.PropertyType : ((FieldInfo)member).FieldType; + return new MemberMapping( + member, + names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + index, + valueType, + format, + dynamicColumn?.Ignore is true, + dynamicColumn?.CustomFormatter); + } + + private static bool TryGetValue( + IDictionary row, + IReadOnlyList names, + out object? value) + { + foreach (var name in names) + { + if (row.TryGetValue(name, out value)) + return true; + var match = row.FirstOrDefault(cell => string.Equals(cell.Key, name, StringComparison.OrdinalIgnoreCase)); + if (match.Key is not null) + { + value = match.Value; + return true; + } + } + value = null; + return false; + } + + private static bool Assign(object? source, out object? value) + { + value = source; + return true; + } + + private static object? ConvertValue( + object? value, + Type targetType, + string? format, + CultureInfo culture) + { + if (value is null || value is DBNull) + { + if (!targetType.IsValueType || Nullable.GetUnderlyingType(targetType) is not null) + return null; + return Activator.CreateInstance(targetType); + } + + var effectiveType = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (effectiveType.IsInstanceOfType(value)) + return value; + if (effectiveType == typeof(string)) + return Convert.ToString(value, culture); + if (effectiveType == typeof(Guid)) + return Guid.Parse(Convert.ToString(value, culture)!); + if (effectiveType == typeof(Uri)) + return new Uri(Convert.ToString(value, culture)!, UriKind.RelativeOrAbsolute); + if (effectiveType == typeof(DateTime)) + { + if (value is double serial) + return DateTime.FromOADate(serial); + var text = Convert.ToString(value, culture)!; + return format is null + ? DateTime.Parse(text, culture) + : DateTime.ParseExact(text, format, CultureInfo.InvariantCulture); + } + if (effectiveType == typeof(DateTimeOffset)) + return DateTimeOffset.Parse(Convert.ToString(value, culture)!, culture); + if (effectiveType == typeof(TimeSpan)) + { + if (value is double milliseconds) + return TimeSpan.FromMilliseconds(milliseconds); + var text = Convert.ToString(value, culture)!; + return format is null + ? TimeSpan.Parse(text, culture) + : TimeSpan.ParseExact(text, format, CultureInfo.InvariantCulture); + } + if (effectiveType == typeof(bool)) + { + var text = Convert.ToString(value, culture); + return text switch { "1" => true, "0" => false, _ => bool.Parse(text!) }; + } + if (effectiveType.IsEnum) + { + var text = Convert.ToString(value, culture)!; + var described = effectiveType.GetFields() + .FirstOrDefault(field => field.GetCustomAttribute()?.Description == text); + return Enum.Parse(effectiveType, described?.Name ?? text, ignoreCase: true); + } + return Convert.ChangeType(value, effectiveType, culture); + } + + private static object? NormalizeWriteValue(object? value) + { + if (value is null) + return null; + var type = value.GetType(); + if (type.IsEnum) + { + var field = type.GetField(value.ToString()!); + return field?.GetCustomAttribute()?.Description ?? value.ToString(); + } + if (value is Guid or Uri) + return value.ToString(); + return value; + } + + private static bool HasMiniExcelAttribute(MemberInfo member) => + member.CustomAttributes.Any(attribute => attribute.AttributeType.Name.IndexOf("Excel", StringComparison.Ordinal) >= 0); + + private static bool IsIgnored(MemberInfo member) => member.CustomAttributes.Any(attribute => + (attribute.AttributeType.Name is "ExcelIgnoreAttribute" or "MiniExcelIgnoreAttribute" && + (attribute.ConstructorArguments.Count == 0 || attribute.ConstructorArguments[0].Value is not false)) || + (attribute.AttributeType.Name is "ExcelColumnAttribute" or "MiniExcelColumnAttribute" && + attribute.NamedArguments.Any(argument => argument.MemberName == "Ignore" && argument.TypedValue.Value is true))); + + private static void AddConstructorName(CustomAttributeData attribute, IList names) + { + if (attribute.ConstructorArguments.Count > 0 && attribute.ConstructorArguments[0].Value is string value && value.Length > 0) + names.Insert(0, value); + } + + private static void AddNamedString(CustomAttributeData attribute, string propertyName, IList names) + { + var argument = attribute.NamedArguments.FirstOrDefault(item => item.MemberName == propertyName); + if (argument.TypedValue.Value is string value && value.Length > 0) + names.Insert(0, value); + } + + private static void AddAliases(CustomAttributeData attribute, ICollection names) + { + if (attribute.ConstructorArguments.Count > 1 && + attribute.ConstructorArguments[1].Value is IEnumerable constructorAliases) + { + foreach (var alias in constructorAliases) + { + if (alias.Value is string value && value.Length > 0) + names.Add(value); + } + } + var argument = attribute.NamedArguments.FirstOrDefault(item => item.MemberName == "Aliases"); + if (argument.TypedValue.Value is IEnumerable aliases) + { + foreach (var alias in aliases) + { + if (alias.Value is string value && value.Length > 0) + names.Add(value); + } + } + } + + private static int? ReadIndex(CustomAttributeData attribute) + { + if (attribute.ConstructorArguments.Count == 0) + return null; + var value = attribute.ConstructorArguments[0].Value; + if (value is int index) + return index; + if (value is string columnName) + return ColumnNameToIndex(columnName); + return null; + } + + private static int? ReadNamedInt(CustomAttributeData attribute, string propertyName) + { + var argument = attribute.NamedArguments.FirstOrDefault(item => item.MemberName == propertyName); + return argument.TypedValue.Value is int value && value >= 0 ? value : null; + } + + private static string? ReadNamedString(CustomAttributeData attribute, string propertyName) + { + var argument = attribute.NamedArguments.FirstOrDefault(item => item.MemberName == propertyName); + return argument.TypedValue.Value as string; + } + + private static Type? ReadNamedType(CustomAttributeData attribute, string propertyName) + { + var argument = attribute.NamedArguments.FirstOrDefault(item => item.MemberName == propertyName); + return argument.TypedValue.Value as Type; + } + + private static string GetLocalizedName(Type resourceType, string key, CultureInfo culture) + { + const BindingFlags flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + var manager = resourceType.GetProperty(nameof(ResourceManager), flags)?.GetValue(null) as ResourceManager + ?? new ResourceManager(resourceType); + return manager.GetString(key, culture) ?? key; + } + + private static int ColumnNameToIndex(string columnName) + { + var index = 0; + foreach (var character in columnName.ToUpperInvariant()) + { + if (character is < 'A' or > 'Z') + throw new ArgumentException($"Invalid Excel column name '{columnName}'."); + index = checked(index * 26 + character - 'A' + 1); + } + return index - 1; + } + + private sealed class MemberMapping( + MemberInfo member, + string[] names, + int? index, + Type valueType, + string? format, + bool ignore, + Func? customFormatter) + { + public string[] Names { get; } = names; + public int? Index { get; } = index; + public Type ValueType { get; } = valueType; + public string? Format { get; } = format; + public bool Ignore { get; } = ignore; + + public void SetValue(object target, object? value) + { + if (member is PropertyInfo property) + property.SetValue(target, value); + else + ((FieldInfo)member).SetValue(target, value); + } + + public object? GetValue(object target) + { + return member is PropertyInfo property + ? property.GetValue(target) + : ((FieldInfo)member).GetValue(target); + } + + public object? FormatValue(object? value) + { + if (customFormatter is null) + return value; + try + { + return customFormatter(value); + } + catch + { + return value; + } + } + } +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRustMappingException.cs b/src/MiniExcelRust/MiniExcelRustMappingException.cs new file mode 100644 index 0000000..bf44e9c --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustMappingException.cs @@ -0,0 +1,36 @@ +namespace MiniExcelLibs; + +public sealed class MiniExcelRustMappingException : InvalidOperationException +{ + internal MiniExcelRustMappingException( + string columnName, + int row, + object? value, + Type targetType, + Exception innerException) + : base($"The value {value} in column {columnName} at row {row} cannot be assigned to {targetType.Name}.", innerException) + { + ColumnName = columnName; + Row = row; + Value = value; + TargetType = targetType; + } + + public string ColumnName { get; } + public int Row { get; } + public object? Value { get; } + public Type TargetType { get; } +} + +public sealed class MiniExcelRustColumnNotFoundException : InvalidOperationException +{ + internal MiniExcelRustColumnNotFoundException(string columnName, int row) + : base($"The mapped column {columnName} was not found at row {row}.") + { + ColumnName = columnName; + Row = row; + } + + public string ColumnName { get; } + public int Row { get; } +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRustPicture.cs b/src/MiniExcelRust/MiniExcelRustPicture.cs new file mode 100644 index 0000000..375cc35 --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustPicture.cs @@ -0,0 +1,20 @@ +namespace MiniExcelLibs; + +public enum MiniExcelRustPictureAnchor : byte +{ + OneCell, + Absolute, + TwoCell +} + +public sealed class MiniExcelRustPicture +{ + public byte[] ImageBytes { get; set; } = Array.Empty(); + public string? SheetName { get; set; } + public string CellAddress { get; set; } = "A1"; + public int WidthPx { get; set; } = 80; + public int HeightPx { get; set; } = 24; + public MiniExcelRustPictureAnchor Anchor { get; set; } + public int LocationX { get; set; } + public int LocationY { get; set; } +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRustRange.cs b/src/MiniExcelRust/MiniExcelRustRange.cs new file mode 100644 index 0000000..f78372b --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustRange.cs @@ -0,0 +1,17 @@ +namespace MiniExcelLibs; + +/// +/// Represents the used A1 range of an XLSX worksheet. +/// +public sealed class MiniExcelRustRange +{ + internal MiniExcelRustRange(string? startCell, string? endCell) + { + StartCell = startCell; + EndCell = endCell; + } + + public string? StartCell { get; } + + public string? EndCell { get; } +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRustReadOptions.cs b/src/MiniExcelRust/MiniExcelRustReadOptions.cs new file mode 100644 index 0000000..8b3daec --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustReadOptions.cs @@ -0,0 +1,24 @@ +namespace MiniExcelLibs; + +/// +/// Configures Rust-backed XLSX queries. +/// +public sealed class MiniExcelRustReadOptions +{ + public System.Globalization.CultureInfo Culture { get; set; } = System.Globalization.CultureInfo.InvariantCulture; + + public IDictionary DynamicColumns { get; } = + new Dictionary(StringComparer.Ordinal); + + public bool IgnoreEmptyRows { get; set; } + + public bool FillMergedCells { get; set; } + + public bool TrimColumnNames { get; set; } = true; + + public bool EnableSharedStringCache { get; set; } = true; + + public ulong SharedStringCacheSize { get; set; } = 5 * 1024 * 1024; + + public string? SharedStringCachePath { get; set; } = Path.GetTempPath(); +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRustSheetInfo.cs b/src/MiniExcelRust/MiniExcelRustSheetInfo.cs new file mode 100644 index 0000000..1d45b1e --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustSheetInfo.cs @@ -0,0 +1,51 @@ +namespace MiniExcelLibs; + +public enum MiniExcelRustSheetType : byte +{ + Worksheet, + DialogSheet, + MacroSheet, + ChartSheet, + Vba +} + +public enum MiniExcelRustSheetState : byte +{ + Visible, + Hidden, + VeryHidden +} + +/// +/// Describes an XLSX sheet in workbook order. +/// +public sealed class MiniExcelRustSheetInfo +{ + internal MiniExcelRustSheetInfo( + uint id, + uint index, + string name, + MiniExcelRustSheetType sheetType, + MiniExcelRustSheetState state, + bool active) + { + Id = id; + Index = index; + Name = name; + SheetType = sheetType; + State = state; + Active = active; + } + + public uint Id { get; } + + public uint Index { get; } + + public string Name { get; } + + public MiniExcelRustSheetType SheetType { get; } + + public MiniExcelRustSheetState State { get; } + + public bool Active { get; } +} \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRustWriteOptions.cs b/src/MiniExcelRust/MiniExcelRustWriteOptions.cs new file mode 100644 index 0000000..08c5ffc --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustWriteOptions.cs @@ -0,0 +1,39 @@ +namespace MiniExcelLibs; + +public enum MiniExcelRustHorizontalAlignment { Left, Center, Right } +public enum MiniExcelRustVerticalAlignment { Bottom, Center, Top } +public enum MiniExcelRustTableStyle { None, Default } + +/// +/// Configures Rust-backed XLSX writes. +/// +public sealed class MiniExcelRustWriteOptions +{ + public string SheetName { get; set; } = "Sheet1"; + public bool OverwriteFile { get; set; } + public bool PrintHeader { get; set; } = true; + public bool AutoFilter { get; set; } = true; + public bool RightToLeft { get; set; } + public bool AutoWidth { get; set; } + public bool WrapCellContents { get; set; } + public MiniExcelRustHorizontalAlignment HorizontalAlignment { get; set; } + public MiniExcelRustVerticalAlignment VerticalAlignment { get; set; } + public MiniExcelRustTableStyle TableStyle { get; set; } = MiniExcelRustTableStyle.Default; + public bool HeaderWrapText { get; set; } + public string HeaderBackgroundColor { get; set; } = "4472C4"; + public MiniExcelRustHorizontalAlignment HeaderHorizontalAlignment { get; set; } + public MiniExcelRustVerticalAlignment HeaderVerticalAlignment { get; set; } + public double MinWidth { get; set; } = 8.42857143; + public double MaxWidth { get; set; } = 200; + public uint FreezeRowCount { get; set; } = 1; + public ushort FreezeColumnCount { get; set; } + public string DateFormat { get; set; } = "yyyy-mm-dd"; + public string TimeFormat { get; set; } = "hh:mm:ss"; + public string DateTimeFormat { get; set; } = "yyyy-mm-dd hh:mm:ss"; + public string DurationFormat { get; set; } = "[h]:mm:ss"; + public IDictionary ColumnFormats { get; } = new Dictionary(); + public IDictionary ColumnWidths { get; } = new Dictionary(); + public IDictionary HiddenColumns { get; } = new Dictionary(); + public IDictionary DynamicColumns { get; } = + new Dictionary(StringComparer.Ordinal); +} \ No newline at end of file diff --git a/tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj b/tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj index 1680d84..19346f3 100644 --- a/tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj +++ b/tests/MiniExcelRust.PackageTests/MiniExcelRust.PackageTests.csproj @@ -11,8 +11,9 @@ - + + diff --git a/tests/MiniExcelRust.PackageTests/Program.cs b/tests/MiniExcelRust.PackageTests/Program.cs index 5d51b7e..01eb44a 100644 --- a/tests/MiniExcelRust.PackageTests/Program.cs +++ b/tests/MiniExcelRust.PackageTests/Program.cs @@ -1,11 +1,18 @@ using System.Diagnostics; +using System.Data; using System.Globalization; using System.IO.Compression; +using System.Resources; using System.Text; using System.Text.Json; +using System.Xml.Linq; using MiniExcelLib; +using MiniExcelLib.Csv; using MiniExcelLib.OpenXml; +using MiniExcelLib.OpenXml.FluentMapping; +using MiniExcelLib.OpenXml.FluentMapping.Api; using MiniExcelLibs; +using MiniExcelLibs.Attributes; using ManagedMiniExcel = MiniExcelLib.MiniExcel; if (args.Length == 0) @@ -16,6 +23,8 @@ "suite" => RunSuite( args.Length >= 2 ? int.Parse(args[1], CultureInfo.InvariantCulture) : 1_000, args.Length >= 3 ? int.Parse(args[2], CultureInfo.InvariantCulture) : 32), + "comments" => VerifyCommentsParity(args), + "merge" => VerifyMergeSameCells(args), "verify" => VerifyFileParity(args), "generate" => GenerateBenchmarkWorkbook(args), "managed" => Benchmark(args, useRust: false), @@ -23,26 +32,1564 @@ _ => Usage() }; +static int VerifyMergeSameCells(string[] arguments) +{ + if (arguments.Length != 2) + return Usage(); + + var sourcePath = Path.GetFullPath(arguments[1]); + var destinationPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-merged-{Guid.NewGuid():N}.xlsx"); + var sourceHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(sourcePath))); + try + { + MiniExcelRust.MergeSameCells(destinationPath, sourcePath); + Require( + ReadMergeReferences(destinationPath).SequenceEqual(new[] { "A2:A4", "C3:C4", "A7:A8" }, StringComparer.Ordinal), + "merge-same-cells: generated ranges differ."); + Require( + MiniExcelRust.Query(destinationPath).SelectMany(row => row.Values).All(value => value is not "@merge" and not "@endmerge"), + "merge-same-cells: marker values remain in output."); + var sourceHashAfter = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(sourcePath))); + Require(sourceHash == sourceHashAfter, "merge-same-cells: source workbook changed."); + + var rejectedOverwrite = false; + try + { + MiniExcelRust.MergeSameCells(destinationPath, sourcePath); + } + catch (InvalidOperationException) + { + rejectedOverwrite = true; + } + Require(rejectedOverwrite, "merge-same-cells: overwrite=false should reject an existing destination."); + MiniExcelRust.MergeSameCells(destinationPath, sourcePath, overwriteFile: true); + + using var outputStream = new MemoryStream(); + MiniExcelRust.MergeSameCells(outputStream, File.ReadAllBytes(sourcePath), leaveOpen: true); + Require(outputStream.CanWrite, "merge-same-cells-stream: leaveOpen should preserve the stream."); + outputStream.Position = 0; + Require( + ReadMergeReferencesFromStream(outputStream).SequenceEqual(new[] { "A2:A4", "C3:C4", "A7:A8" }, StringComparer.Ordinal), + "merge-same-cells-stream: generated ranges differ."); + Console.WriteLine("Verified merge-same-cells output and source preservation."); + return 0; + } + finally + { + if (File.Exists(destinationPath)) + File.Delete(destinationPath); + } +} + +static List ReadMergeReferences(string path) +{ + using var stream = File.OpenRead(path); + return ReadMergeReferencesFromStream(stream); +} + +static List ReadMergeReferencesFromStream(Stream stream) +{ + using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); + var entry = archive.GetEntry("xl/worksheets/sheet1.xml") + ?? throw new InvalidDataException("The workbook has no first worksheet."); + using var entryStream = entry.Open(); + var document = XDocument.Load(entryStream); + XNamespace spreadsheet = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + return document.Descendants(spreadsheet + "mergeCell") + .Select(element => (string?)element.Attribute("ref") ?? string.Empty) + .ToList(); +} + +static int VerifyCommentsParity(string[] arguments) +{ + if (arguments.Length is < 2 or > 3) + return Usage(); + + var path = Path.GetFullPath(arguments[1]); + var sheetName = arguments.Length == 3 ? arguments[2] : null; + var importer = ManagedMiniExcel.Importers.GetOpenXmlImporter(); + var managed = importer.RetrieveComments(path, sheetName); + var rust = MiniExcelRust.RetrieveComments(path, sheetName); + Require(string.Equals(managed.SheetName, rust.SheetName, StringComparison.OrdinalIgnoreCase), "comments: sheet name differs."); + Require(managed.Comments.Count == rust.Comments.Count, "comments: threaded comment count differs."); + Require(managed.Notes.Count == rust.Notes.Count, "comments: note count differs."); + + for (var index = 0; index < managed.Comments.Count; index++) + { + var expected = managed.Comments[index]; + var actual = rust.Comments[index]; + Require(expected.Id == actual.Id, $"comments: id differs at {index}."); + Require(expected.ReferenceCell == actual.ReferenceCell, $"comments: cell differs at {index}."); + Require(expected.Resolved == actual.Resolved, $"comments: resolved differs at {index}."); + Require(expected.Text == actual.Text, $"comments: text differs at {index}."); + Require(expected.CreatedAt == actual.CreatedAt, $"comments: timestamp differs at {index}."); + CompareAuthors(expected.Author, actual.Author, $"comments[{index}].author"); + Require(expected.Replies.Count == actual.Replies.Count, $"comments: reply count differs at {index}."); + for (var replyIndex = 0; replyIndex < expected.Replies.Count; replyIndex++) + { + var expectedReply = expected.Replies[replyIndex]; + var actualReply = actual.Replies[replyIndex]; + Require(expectedReply.Id == actualReply.Id, $"comments: reply id differs at {index},{replyIndex}."); + Require(expectedReply.ParentId == actualReply.ParentId, $"comments: parent id differs at {index},{replyIndex}."); + Require(expectedReply.Text == actualReply.Text, $"comments: reply text differs at {index},{replyIndex}."); + Require(expectedReply.CreatedAt == actualReply.CreatedAt, $"comments: reply timestamp differs at {index},{replyIndex}."); + CompareAuthors(expectedReply.Author, actualReply.Author, $"comments[{index}].replies[{replyIndex}].author"); + } + } + + var missingNoteIds = 0; + for (var index = 0; index < managed.Notes.Count; index++) + { + var expected = managed.Notes[index]; + var actual = rust.Notes[index]; + Require(expected.ReferenceCell == actual.ReferenceCell, $"comments: note cell differs at {index}."); + Require(expected.Author == actual.Author, $"comments: note author differs at {index}."); + Require(expected.Text == actual.Text, $"comments: note text differs at {index}."); + if (actual.Id is null) + missingNoteIds++; + else + Require(expected.Id == actual.Id, $"comments: note id differs at {index}."); + } + + using var stream = File.OpenRead(path); + var streamResult = MiniExcelRust.RetrieveComments(stream, sheetName, leaveOpen: true); + Require(streamResult.Comments.Count == rust.Comments.Count, "comments-stream: comment count differs."); + Require(stream.CanRead, "comments-stream: leaveOpen should preserve the stream."); + Console.WriteLine($"Verified comments for {rust.SheetName}; missing Rust legacy-note IDs: {missingNoteIds}."); + return 0; +} + +static void CompareAuthors( + MiniExcelLib.OpenXml.Models.Author? expected, + MiniExcelRustCommentAuthor? actual, + string scenario) +{ + Require((expected is null) == (actual is null), $"{scenario}: presence differs."); + if (expected is null || actual is null) + return; + Require(expected.Id == actual.Id, $"{scenario}: id differs."); + Require(expected.DisplayName == actual.DisplayName, $"{scenario}: display name differs."); + Require(expected.ProviderId == actual.ProviderId, $"{scenario}: provider id differs."); +} + static int RunSuite(int lifecycleIterations, int maxPrivateGrowthMb) { - var workbookPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + var workbookPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + var csvPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.csv"); + try + { + CreateWorkbook(workbookPath); + File.WriteAllText(csvPath, "Name;Note\r\nalpha;\"Taiwan 台灣\"\r\nbeta;\r\n", new UTF8Encoding(true)); + VerifyParity(workbookPath); + VerifyMissingDimensionParity(workbookPath); + VerifySelfClosingEmptyRowParity(workbookPath); + VerifyCompatibilityFacade(workbookPath); + VerifyCsvParity(csvPath); + VerifyConversions(); + VerifySaveAs(); + VerifyMultiSheetSaveAs(); + VerifyConfiguredWrite(); + VerifyCsvWrite(); + VerifyTypedConversions(); + VerifyTypedExports(); + VerifyFluentMapping(); + VerifyInsertAndCopy(); + VerifyTemplateFill(); + VerifyPictures(); + VerifyWorkbookMutations(workbookPath); + VerifyLifecycle(workbookPath, lifecycleIterations, maxPrivateGrowthMb); + Console.WriteLine("MiniExcelRust parity and lifecycle suite passed."); + return 0; + } + finally + { + if (File.Exists(workbookPath)) + File.Delete(workbookPath); + if (File.Exists(csvPath)) + File.Delete(csvPath); + } +} + +static void VerifyFluentMapping() +{ + var path = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-fluent-{Guid.NewGuid():N}.xlsx"); + var formulaPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-fluent-formula-{Guid.NewGuid():N}.xlsx"); + var formulaTemplatePath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-fluent-formula-template-{Guid.NewGuid():N}.xlsx"); + var managedPath = Path.Combine(Path.GetTempPath(), $"miniexcel-managed-fluent-{Guid.NewGuid():N}.xlsx"); + var oraclePath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-fluent-oracle-{Guid.NewGuid():N}.xlsx"); + var templatePath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-fluent-template-{Guid.NewGuid():N}.xlsx"); + var mappedTemplatePath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-fluent-template-output-{Guid.NewGuid():N}.xlsx"); + var invalidPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-fluent-invalid-{Guid.NewGuid():N}.xlsx"); + try + { + var mapping = new MiniExcelRustMapping().ToWorksheet("Mapped"); + mapping.Property(value => value.Name).ToCell("A1"); + mapping.Collection(value => value.PhoneNumbers).StartAt("A3").WithSpacing(1); + mapping.Collection(value => value.Projects) + .StartAt("C3") + .WithItemMapping(project => + { + project.Property(value => value.Code).ToCell("C3"); + project.Collection(value => value.Tasks) + .StartAt("D3") + .WithItemMapping(task => + { + task.Property(value => value.Name).ToCell("D3"); + task.Property(value => value.Hours).ToCell("E3"); + }); + }); + + var source = new[] + { + new MappedDepartment + { + Name = "Engineering", + PhoneNumbers = ["555-1000", "555-2000"], + Projects = + [ + new MappedProject + { + Code = "P1", + Tasks = [new MappedTask { Name = "Design", Hours = 2 }, new MappedTask { Name = "Build", Hours = 5 }] + }, + new MappedProject + { + Code = "P2", + Tasks = [new MappedTask { Name = "Test", Hours = 3 }] + } + ] + } + }; + var written = MiniExcelRustMappingExtensions.ExportMapped(path, source, mapping); + Require(written == 5, $"fluent-mapping: expected 5 physical rows, received {written}."); + var rows = MiniExcelRust.Query(path, false, "Mapped").ToList(); + Require(rows.Count == 5, $"fluent-mapping: expected 5 query rows, received {rows.Count}."); + Require(Equals(rows[0]["A"], "Engineering"), "fluent-mapping: scalar cell differs."); + Require(Equals(rows[2]["A"], "555-1000") && Equals(rows[4]["A"], "555-2000"), "fluent-mapping: collection spacing differs."); + Require(Equals(rows[2]["C"], "P1") && Equals(rows[2]["D"], "Design"), "fluent-mapping: first nested item differs."); + Require(Equals(rows[3]["D"], "Build") && Convert.ToInt32(rows[3]["E"], CultureInfo.InvariantCulture) == 5, "fluent-mapping: nested collection expansion differs."); + Require(Equals(rows[4]["C"], "P2") && Equals(rows[4]["D"], "Test"), "fluent-mapping: second parent item overlapped the first."); + var managedRegistry = new MappingRegistry(); + managedRegistry.Configure(configuration => + { + configuration.ToWorksheet("Mapped"); + configuration.Property(value => value.Name).ToCell("A1"); + configuration.Collection(value => value.PhoneNumbers).StartAt("A3").WithSpacing(1); + configuration.Collection(value => value.Projects) + .StartAt("C3") + .WithItemMapping(project => + { + project.Property(value => value.Code).ToCell("C3"); + project.Collection(value => value.Tasks) + .StartAt("D3") + .WithItemMapping(task => + { + task.Property(value => value.Name).ToCell("D3"); + task.Property(value => value.Hours).ToCell("E3"); + }); + }); + }); + var oracleSource = new[] + { + new MappedDepartment + { + Name = source[0].Name, + PhoneNumbers = source[0].PhoneNumbers, + Projects = [source[0].Projects[0]] + } + }; + ManagedMiniExcel.Exporters.GetMappingExporter(managedRegistry).Export(managedPath, oracleSource, overwriteFile: true); + MiniExcelRustMappingExtensions.ExportMapped(oraclePath, oracleSource, mapping); + CompareRows( + QueryManaged(managedPath, useHeaderRow: false, sheetName: "Mapped").ToList(), + MiniExcelRust.Query(oraclePath, false, "Mapped").ToList(), + "fluent-mapping-source-oracle"); + var roundTrip = MiniExcelRustMappingExtensions.ReadMapped(path, mapping); + Require(roundTrip.Name == "Engineering", "fluent-mapping-read: scalar property differs."); + Require(roundTrip.PhoneNumbers.SequenceEqual(new[] { "555-1000", "555-2000" }), "fluent-mapping-read: simple collection differs."); + Require(roundTrip.Projects.Count == 2, "fluent-mapping-read: complex collection count differs."); + Require(roundTrip.Projects[0].Tasks.Count == 2 && roundTrip.Projects[0].Tasks[1].Name == "Build", "fluent-mapping-read: first nested collection differs."); + Require(roundTrip.Projects[1].Code == "P2" && roundTrip.Projects[1].Tasks.Single().Hours == 3, "fluent-mapping-read: second nested collection differs."); + using (var mappedStream = File.OpenRead(path)) + { + var streamRoundTrip = MiniExcelRustMappingExtensions.ReadMapped(mappedStream, mapping, leaveOpen: true); + Require(streamRoundTrip.Projects.Count == 2 && mappedStream.CanRead, "fluent-mapping-read-stream: values or ownership differ."); + } + var asyncRoundTrip = MiniExcelRustMappingExtensions.ReadMappedAsync(path, mapping).GetAwaiter().GetResult(); + Require(asyncRoundTrip.Projects[1].Code == "P2", "fluent-mapping-read-async: complex collection differs."); + using (var exportStream = new MemoryStream()) + { + var streamCount = MiniExcelRustMappingExtensions.ExportMappedAsync(exportStream, source, mapping, leaveOpen: true).GetAwaiter().GetResult(); + exportStream.Position = 0; + var streamRows = MiniExcelRust.Query(exportStream, false, "Mapped", leaveOpen: true).ToList(); + Require(streamCount == 5 && Equals(streamRows[4]["C"], "P2") && exportStream.CanRead, "fluent-mapping-export-stream: values or ownership differ."); + } + + var formulaMapping = new MiniExcelRustMapping(); + formulaMapping.Property(value => value.Name).ToCell("A1"); + formulaMapping.Property(value => value.Amount).ToCell("B1").WithFormat("#,##0.00"); + formulaMapping.Property(value => value.Total).ToCell("C1").WithFormula("=B1*2"); + formulaMapping.Property(value => value.Name).ToCell("C2"); + MiniExcelRustMappingExtensions.ExportMapped( + formulaPath, + new[] { new MappedFormula { Name = "Line", Amount = 12.5 } }, + formulaMapping); + var formulaXml = ReadZipEntryText(formulaPath, "xl/worksheets/sheet1.xml"); + var stylesXml = ReadZipEntryText(formulaPath, "xl/styles.xml"); + Require(formulaXml.Contains("B1*2", StringComparison.Ordinal), "fluent-mapping: formula cell was not written."); + Require(MiniExcelRust.Query(formulaPath, false).ElementAt(1)["C"]?.ToString() == "Line", "fluent-mapping: a regular cell in the formula column was changed."); + Require(stylesXml.Contains("#,##0.00", StringComparison.Ordinal), "fluent-mapping: number format was not written."); + MiniExcelRustMappingExtensions.FillMappedTemplate( + formulaTemplatePath, + formulaPath, + new[] { new MappedFormula { Name = "Updated", Amount = 7.5 } }, + formulaMapping); + var formulaTemplateXml = ReadZipEntryText(formulaTemplatePath, "xl/worksheets/sheet1.xml"); + Require(formulaTemplateXml.Contains("B1*2", StringComparison.Ordinal), "fluent-template: formula cell was not overlaid."); + var formulaTemplateRows = MiniExcelRust.Query(formulaTemplatePath, false).ToList(); + Require(Equals(formulaTemplateRows[0]["A"], "Updated") && Convert.ToDouble(formulaTemplateRows[0]["B"], CultureInfo.InvariantCulture) == 7.5, "fluent-template: formula source cells differ."); + + var templateRows = Enumerable.Range(1, 5) + .Select(index => (IDictionary)new Dictionary + { + ["A"] = index == 1 ? "Original" : null, + ["F"] = $"Keep-{index}" + }) + .ToList(); + var templateOptions = new MiniExcelRustWriteOptions { SheetName = "Mapped", PrintHeader = false }; + templateOptions.ColumnFormats["A"] = "@"; + MiniExcelRust.SaveAsWithSchema(templatePath, new[] { "A", "B", "C", "D", "E", "F" }, templateRows, templateOptions); + var templateDocument = XDocument.Parse(ReadZipEntryText(templatePath, "xl/worksheets/sheet1.xml")); + var templateStyle = templateDocument.Descendants().Single(element => element.Name.LocalName == "c" && (string?)element.Attribute("r") == "A1").Attribute("s")?.Value; + MiniExcelRustMappingExtensions.FillMappedTemplate(mappedTemplatePath, templatePath, source, mapping); + var mappedRows = MiniExcelRust.Query(mappedTemplatePath, false, "Mapped").ToList(); + Require(Equals(mappedRows[0]["A"], "Engineering") && Equals(mappedRows[0]["F"], "Keep-1"), "fluent-template: mapped or unrelated scalar cell differs."); + Require(Equals(mappedRows[3]["D"], "Build") && Equals(mappedRows[4]["C"], "P2"), "fluent-template: nested collection rows differ."); + Require(Equals(mappedRows[4]["F"], "Keep-5"), "fluent-template: unrelated template cells were not preserved."); + var mappedDocument = XDocument.Parse(ReadZipEntryText(mappedTemplatePath, "xl/worksheets/sheet1.xml")); + var mappedStyle = mappedDocument.Descendants().Single(element => element.Name.LocalName == "c" && (string?)element.Attribute("r") == "A1").Attribute("s")?.Value; + Require(templateStyle is not null && mappedStyle == templateStyle, "fluent-template: existing target style was not preserved."); + + using (var outputStream = new MemoryStream()) + { + MiniExcelRustMappingExtensions.FillMappedTemplateAsync(outputStream, File.ReadAllBytes(templatePath), source, mapping, leaveOpen: true).GetAwaiter().GetResult(); + outputStream.Position = 0; + var streamRows = MiniExcelRust.Query(outputStream, false, "Mapped", leaveOpen: true).ToList(); + Require(Equals(streamRows[4]["C"], "P2") && outputStream.CanRead, "fluent-template-stream: values or ownership differ."); + } + + var missingCell = new MiniExcelRustMapping(); + missingCell.Property(value => value.Name); + var rejectedMissingCell = false; + try + { + MiniExcelRustMappingExtensions.ExportMapped(invalidPath, new[] { new MappedFormula() }, missingCell); + } + catch (InvalidOperationException) + { + rejectedMissingCell = true; + } + Require(rejectedMissingCell, "fluent-mapping: missing ToCell should fail."); + + var missingStart = new MiniExcelRustMapping(); + missingStart.Collection(value => value.PhoneNumbers); + var rejectedMissingStart = false; + try + { + MiniExcelRustMappingExtensions.ExportMapped(invalidPath, new[] { new MappedDepartment() }, missingStart); + } + catch (InvalidOperationException) + { + rejectedMissingStart = true; + } + Require(rejectedMissingStart, "fluent-mapping: missing StartAt should fail."); + } + finally + { + foreach (var candidate in new[] { path, formulaPath, formulaTemplatePath, managedPath, oraclePath, templatePath, mappedTemplatePath, invalidPath }) + if (File.Exists(candidate)) + File.Delete(candidate); + } +} + +static void VerifyMissingDimensionParity(string sourcePath) +{ + var path = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-no-dimension-{Guid.NewGuid():N}.xlsx"); + File.Copy(sourcePath, path); + try + { + using (var archive = ZipFile.Open(path, ZipArchiveMode.Update)) + { + foreach (var entryName in new[] + { + "xl/worksheets/sheet1.xml", + "xl/worksheets/sheet2.xml", + "xl/worksheets/sheet3.xml" + }) + { + var entry = archive.GetEntry(entryName) ?? throw new InvalidDataException($"Missing {entryName}."); + XDocument document; + using (var input = entry.Open()) + document = XDocument.Load(input); + XNamespace spreadsheet = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + document.Root?.Element(spreadsheet + "dimension")?.Remove(); + entry.Delete(); + var replacement = archive.CreateEntry(entryName, CompressionLevel.Fastest); + using var output = replacement.Open(); + document.Save(output); + } + } + + var importer = ManagedMiniExcel.Importers.GetOpenXmlImporter(); + var managed = importer.GetSheetDimensions(path); + var rust = MiniExcelRust.GetSheetDimensions(path); + Require(managed.Count == rust.Count, "missing-dimension: sheet count differs."); + for (var index = 0; index < managed.Count; index++) + { + Require(managed[index].StartCell == rust[index].StartCell, $"missing-dimension: start differs at {index}."); + Require(managed[index].EndCell == rust[index].EndCell, $"missing-dimension: end differs at {index}."); + } + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } +} + +static void VerifySelfClosingEmptyRowParity(string sourcePath) +{ + var path = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-self-closing-{Guid.NewGuid():N}.xlsx"); + File.Copy(sourcePath, path); + try + { + using (var archive = ZipFile.Open(path, ZipArchiveMode.Update)) + { + const string entryName = "xl/worksheets/sheet3.xml"; + var entry = archive.GetEntry(entryName) ?? throw new InvalidDataException($"Missing {entryName}."); + string xml; + using (var reader = new StreamReader(entry.Open(), Encoding.UTF8)) + xml = reader.ReadToEnd(); + xml = xml.Replace("", "", StringComparison.Ordinal); + entry.Delete(); + var replacement = archive.CreateEntry(entryName, CompressionLevel.Fastest); + using var writer = new StreamWriter(replacement.Open(), new UTF8Encoding(false)); + writer.Write(xml); + } + + var managedConfiguration = new OpenXmlConfiguration { IgnoreEmptyRows = true }; + var rustConfiguration = new MiniExcelRustReadOptions { IgnoreEmptyRows = true }; + var managed = QueryManaged(path, true, "Options", "A1", managedConfiguration).ToList(); + var rust = MiniExcelRust.Query(path, true, "Options", "A1", rustConfiguration).ToList(); + CompareRows(managed, rust, "self-closing-empty-row"); + Require(rust.Count == 2, $"self-closing-empty-row: expected 2 rows, received {rust.Count}."); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } +} + +static void VerifyConversions() +{ + var csvPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-convert-{Guid.NewGuid():N}.csv"); + var xlsxPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-convert-{Guid.NewGuid():N}.xlsx"); + var roundtripPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-roundtrip-{Guid.NewGuid():N}.csv"); + try + { + File.WriteAllText(csvPath, "Name,Value\r\nalpha,1\r\nbeta,2\r\n", new UTF8Encoding(true)); + var expected = MiniExcelRust.QueryCsv(csvPath, true).ToList(); + MiniExcelRust.ConvertCsvToXlsxAsync(csvPath, xlsxPath, true).GetAwaiter().GetResult(); + CompareRows(expected, MiniExcelRust.Query(xlsxPath, true).ToList(), "convert-csv-xlsx"); + MiniExcelRust.ConvertXlsxToCsvAsync(xlsxPath, roundtripPath, true).GetAwaiter().GetResult(); + CompareRows(expected, MiniExcelRust.QueryCsv(roundtripPath, true).ToList(), "convert-xlsx-csv"); + + using var csvInput = new MemoryStream(File.ReadAllBytes(csvPath)); + using var xlsxOutput = new MemoryStream(); + MiniExcelRust.ConvertCsvToXlsx(csvInput, xlsxOutput, true); + Require(csvInput.CanRead && xlsxOutput.CanWrite, "convert-stream: stream ownership changed."); + xlsxOutput.Position = 0; + using var csvOutput = new MemoryStream(); + MiniExcelRust.ConvertXlsxToCsv(xlsxOutput, csvOutput, true); + csvOutput.Position = 0; + CompareRows(expected, MiniExcelRust.QueryCsv(csvOutput, true, leaveOpen: true).ToList(), "convert-stream-roundtrip"); + } + finally + { + foreach (var path in new[] { csvPath, xlsxPath, roundtripPath }) + { + if (File.Exists(path)) + File.Delete(path); + } + } +} + +static void VerifyCompatibilityFacade(string path) +{ + var facade = typeof(MiniExcelRust).Assembly.GetType("MiniExcelLibs.MiniExcel") + ?? throw new InvalidOperationException("compatibility-facade: type was not found."); + var assembly = typeof(MiniExcelRust).Assembly; + var excelType = assembly.GetType("MiniExcelLibs.ExcelType") + ?? throw new InvalidOperationException("compatibility-facade: ExcelType was not found."); + var configuration = assembly.GetType("MiniExcelLibs.IConfiguration") + ?? throw new InvalidOperationException("compatibility-facade: IConfiguration was not found."); + Require(assembly.GetType("MiniExcelLibs.OpenXml.OpenXmlConfiguration") is not null, "compatibility-facade: OpenXmlConfiguration was not found."); + Require(assembly.GetType("MiniExcelLibs.Csv.CsvConfiguration") is not null, "compatibility-facade: CsvConfiguration was not found."); + Require( + facade.GetMethods().Any(candidate => + candidate.Name == "Query" && + candidate.GetParameters().Any(parameter => parameter.ParameterType == excelType) && + candidate.GetParameters().Any(parameter => parameter.ParameterType == configuration)), + "compatibility-facade: configured Query overload was not found."); + var methodNames = facade.GetMethods().Select(method => method.Name).ToHashSet(StringComparer.Ordinal); + foreach (var required in new[] + { + "AddPicture", "AddPictureAsync", "Query", "QueryAsync", "QueryRange", "QueryRangeAsync", "SaveAs", "SaveAsAsync", + "Insert", "SaveAsByTemplate", "MergeSameCells", "MergeSameCellsAsync", "GetReader", + "QueryAsDataTable", "QueryAsDataTableAsync", "GetSheetNames", "GetSheetInformations", + "GetSheetDimensions", "GetColumns", "GetColumnsAsync", + "ConvertCsvToXlsx", "ConvertCsvToXlsxAsync", "ConvertXlsxToCsv", "ConvertXlsxToCsvAsync" + }) + Require(methodNames.Contains(required), $"compatibility-facade: {required} was not found."); + var expectedOverloadCounts = new Dictionary(StringComparer.Ordinal) + { + ["AddPicture"] = 2, + ["AddPictureAsync"] = 2, + ["ConvertCsvToXlsx"] = 2, + ["ConvertCsvToXlsxAsync"] = 2, + ["ConvertXlsxToCsv"] = 2, + ["ConvertXlsxToCsvAsync"] = 2, + ["GetColumns"] = 2, + ["GetColumnsAsync"] = 2, + ["GetReader"] = 2, + ["GetSheetDimensions"] = 2, + ["GetSheetDimensionsAsync"] = 2, + ["GetSheetInformations"] = 2, + ["GetSheetInformationsAsync"] = 2, + ["GetSheetNames"] = 2, + ["GetSheetNamesAsync"] = 2, + ["Insert"] = 2, + ["InsertAsync"] = 2, + ["MergeSameCells"] = 3, + ["MergeSameCellsAsync"] = 3, + ["Query"] = 4, + ["QueryAsync"] = 4, + ["QueryRange"] = 4, + ["QueryRangeAsync"] = 4, + ["QueryAsDataTable"] = 2, + ["QueryAsDataTableAsync"] = 2, + ["SaveAs"] = 2, + ["SaveAsAsync"] = 2, + ["SaveAsByTemplate"] = 6, + ["SaveAsByTemplateAsync"] = 6 + }; + foreach (var expected in expectedOverloadCounts) + { + var actual = facade.GetMethods().Count(candidate => candidate.Name == expected.Key); + Require( + actual >= expected.Value, + $"compatibility-facade: {expected.Key} has {actual} overloads; baseline requires {expected.Value}."); + } + var method = facade.GetMethod("GetSheetNames", new[] { typeof(string) }) + ?? throw new InvalidOperationException("compatibility-facade: GetSheetNames was not found."); + var names = (List?)method.Invoke(null, new object[] { path }) + ?? throw new InvalidOperationException("compatibility-facade: GetSheetNames returned null."); + Require( + names.SequenceEqual(new[] { "Sheet1", "Data", "Options" }, StringComparer.Ordinal), + "compatibility-facade: sheet names differ."); + + var outputPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-facade-{Guid.NewGuid():N}.xlsx"); + try + { + var dataSet = new DataSet(); + var first = new DataTable("First"); + first.Columns.Add("Name", typeof(string)); + first.Rows.Add("one"); + var second = new DataTable("Second"); + second.Columns.Add("Value", typeof(double)); + second.Rows.Add(2d); + dataSet.Tables.Add(first); + dataSet.Tables.Add(second); + var saveAs = facade.GetMethods().Single(candidate => + candidate.Name == "SaveAs" && + !candidate.IsGenericMethod && + candidate.GetParameters().Length == 7 && + candidate.GetParameters()[0].ParameterType == typeof(string) && + candidate.GetParameters()[1].ParameterType == typeof(object)); + var unknown = Enum.Parse(excelType, "UNKNOWN"); + var counts = (int[]?)saveAs.Invoke( + null, + new object?[] { outputPath, dataSet, true, "Sheet1", unknown, null, false }); + Require(counts?.SequenceEqual(new[] { 1, 1 }) is true, "compatibility-facade: DataSet counts differ."); + Require( + MiniExcelRust.GetSheetNames(outputPath).SequenceEqual(new[] { "First", "Second" }, StringComparer.Ordinal), + "compatibility-facade: DataSet sheet names differ."); + } + finally + { + if (File.Exists(outputPath)) + File.Delete(outputPath); + } +} + +static void VerifySaveAs() +{ + var path = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-write-{Guid.NewGuid():N}.xlsx"); + var rows = new List> + { + new Dictionary + { + ["Name"] = "alpha", + ["Value"] = 42d, + ["Enabled"] = true, + ["When"] = new DateTime(2026, 9, 6, 12, 34, 56, 789), + ["Duration"] = TimeSpan.FromMilliseconds(3723004), + ["Date"] = new DateOnly(2026, 9, 6), + ["Time"] = new TimeOnly(12, 34, 56, 789) + }, + new Dictionary + { + ["Name"] = "beta", + ["Value"] = null, + ["Enabled"] = false, + ["When"] = null, + ["Duration"] = null, + ["Date"] = null, + ["Time"] = null + } + }; + try + { + var progress = new CountingProgress(); + var written = MiniExcelRust.SaveAs(path, rows, sheetName: "Exported", progress: progress); + Require(written == rows.Count, $"save-as: expected {rows.Count} written rows, received {written}."); + Require(progress.Count == rows.Sum(row => row.Count), "save-as: progress count differs."); + var managedRows = QueryManaged(path, true, "Exported").ToList(); + var rustRows = MiniExcelRust.Query(path, true, "Exported").ToList(); + CompareRows(managedRows, rustRows, "save-as-roundtrip"); + + var rejectedExistingFile = false; + try + { + MiniExcelRust.SaveAs(path, rows); + } + catch (InvalidOperationException) + { + rejectedExistingFile = true; + } + Require(rejectedExistingFile, "save-as: overwrite=false should reject an existing file."); + Require(progress.Count == rows.Sum(row => row.Count), "save-as: failed write changed progress."); + + written = MiniExcelRust.SaveAs(path, rows, sheetName: "Exported", overwriteFile: true); + Require(written == rows.Count, "save-as: overwrite=true did not rewrite the workbook."); + + using (var stream = new MemoryStream()) + { + written = MiniExcelRust.SaveAs(stream, rows, sheetName: "Streamed", leaveOpen: true); + Require(written == rows.Count, "save-as-stream: row count differs."); + Require(stream.CanWrite, "save-as-stream: leaveOpen should preserve the stream."); + stream.Position = 0; + var importer = ManagedMiniExcel.Importers.GetOpenXmlImporter(); + var managedStreamRows = importer.Query(stream, true, "Streamed", leaveOpen: true) + .Cast>() + .ToList(); + CompareRows(managedRows, managedStreamRows, "save-as-stream"); + } + + var closingStream = new MemoryStream(); + MiniExcelRust.SaveAs(closingStream, rows); + Require(!closingStream.CanWrite, "save-as-stream: the default should close the stream."); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } +} + +static void VerifyMultiSheetSaveAs() +{ + var path = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-multisheet-{Guid.NewGuid():N}.xlsx"); + var firstRows = new List> + { + new Dictionary { ["Name"] = "one", ["Value"] = 1d }, + new Dictionary { ["Name"] = "two", ["Value"] = 2d } + }; + var secondRows = new List> + { + new Dictionary { ["Code"] = "A", ["Enabled"] = true } + }; + var sheets = new[] + { + new KeyValuePair>>("First", firstRows), + new KeyValuePair>>("Second", secondRows) + }; + try + { + var counts = MiniExcelRust.SaveAsSheets(path, sheets); + Require(counts.SequenceEqual(new[] { 2, 1 }), "multi-sheet-save: row counts differ."); + Require( + MiniExcelRust.GetSheetNames(path).SequenceEqual(new[] { "First", "Second" }, StringComparer.Ordinal), + "multi-sheet-save: sheet order differs."); + CompareRows(firstRows, QueryManaged(path, true, "First").ToList(), "multi-sheet-first"); + CompareRows(secondRows, QueryManaged(path, true, "Second").ToList(), "multi-sheet-second"); + + var rejectedOverwrite = false; + try + { + MiniExcelRust.SaveAsSheets(path, sheets); + } + catch (InvalidOperationException) + { + rejectedOverwrite = true; + } + Require(rejectedOverwrite, "multi-sheet-save: overwrite=false should reject an existing file."); + counts = MiniExcelRust.SaveAsSheets(path, sheets, overwriteFile: true); + Require(counts.SequenceEqual(new[] { 2, 1 }), "multi-sheet-save: overwrite counts differ."); + + using var stream = new MemoryStream(); + counts = MiniExcelRust.SaveAsSheets(stream, sheets, leaveOpen: true); + Require(counts.SequenceEqual(new[] { 2, 1 }) && stream.CanWrite, "multi-sheet-stream: write failed."); + stream.Position = 0; + Require( + MiniExcelRust.GetSheetNames(stream, leaveOpen: true).SequenceEqual(new[] { "First", "Second" }, StringComparer.Ordinal), + "multi-sheet-stream: sheet order differs."); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } +} + +static void VerifyConfiguredWrite() +{ + var path = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-configured-{Guid.NewGuid():N}.xlsx"); + var schema = new[] { "Name", "Amount", "Secret" }; + var rows = new[] + { + new Dictionary { ["Amount"] = 12.5d, ["Name"] = "alpha", ["Secret"] = "hidden" } + }; + var options = new MiniExcelRustWriteOptions + { + SheetName = "Styled", + AutoFilter = true, + RightToLeft = true, + WrapCellContents = true, + HorizontalAlignment = MiniExcelRustHorizontalAlignment.Center, + VerticalAlignment = MiniExcelRustVerticalAlignment.Top, + TableStyle = MiniExcelRustTableStyle.Default, + HeaderWrapText = true, + HeaderBackgroundColor = "2F5597", + HeaderHorizontalAlignment = MiniExcelRustHorizontalAlignment.Center, + HeaderVerticalAlignment = MiniExcelRustVerticalAlignment.Top, + FreezeRowCount = 2, + FreezeColumnCount = 1 + }; + options.ColumnFormats["Amount"] = "0.00"; + options.ColumnWidths["Amount"] = 22; + options.HiddenColumns["Secret"] = true; + try + { + var written = MiniExcelRust.SaveAsWithSchema(path, schema, rows, options); + Require(written == 1, "configured-write: row count differs."); + var managedRows = QueryManaged(path, true, "Styled").ToList(); + Require(managedRows[0].Keys.SequenceEqual(schema, StringComparer.Ordinal), "configured-write: schema order differs."); + Require(Equals(managedRows[0]["Name"], "alpha"), "configured-write: data differs."); + + var worksheetXml = ReadZipEntryText(path, "xl/worksheets/sheet1.xml"); + Require(worksheetXml.Contains("rightToLeft=\"1\"", StringComparison.Ordinal), "configured-write: RTL missing."); + Require(worksheetXml.Contains("xSplit=\"1\"", StringComparison.Ordinal), "configured-write: frozen column missing."); + Require(worksheetXml.Contains("ySplit=\"2\"", StringComparison.Ordinal), "configured-write: frozen rows missing."); + Require(worksheetXml.Contains("