From 12890be18a837e8dbe5babee3a040bae9ab347ae Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sat, 5 Sep 2026 23:09:22 +0800 Subject: [PATCH 01/17] Show benchmark results on project homepage --- .github/workflows/benchmarks.yml | 4 +++- README.md | 29 +++++++++++++++++++--------- build/Publish-Benchmark-Results.ps1 | 30 ++++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 11 deletions(-) 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/README.md b/README.md index 5fe242c..57363ba 100644 --- a/README.md +++ b/README.md @@ -101,15 +101,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. - -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. +### 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 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 From 139e0258caa6170f928e090e3622028b5907c07e Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sun, 6 Sep 2026 01:31:23 +0800 Subject: [PATCH 02/17] Expand Rust-backed XLSX and CSV read parity --- README.md | 37 +- build/Test-Package.ps1 | 10 +- docs/miniexcel-rust-parity-plan.md | 253 +++++ docs/parity-matrix.md | 57 ++ native/miniexcel-ffi/src/lib.rs | 655 ++++++++++++- src/MiniExcelRust/MiniExcelRust.cs | 913 +++++++++++++++++- .../MiniExcelRustCsvReadOptions.cs | 24 + src/MiniExcelRust/MiniExcelRustRange.cs | 17 + src/MiniExcelRust/MiniExcelRustReadOptions.cs | 19 + src/MiniExcelRust/MiniExcelRustSheetInfo.cs | 51 + .../MiniExcelRust.PackageTests.csproj | 3 +- tests/MiniExcelRust.PackageTests/Program.cs | 310 +++++- 12 files changed, 2309 insertions(+), 40 deletions(-) create mode 100644 docs/miniexcel-rust-parity-plan.md create mode 100644 docs/parity-matrix.md create mode 100644 src/MiniExcelRust/MiniExcelRustCsvReadOptions.cs create mode 100644 src/MiniExcelRust/MiniExcelRustRange.cs create mode 100644 src/MiniExcelRust/MiniExcelRustReadOptions.cs create mode 100644 src/MiniExcelRust/MiniExcelRustSheetInfo.cs diff --git a/README.md b/README.md index 57363ba..1337868 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 dynamic +> XLSX and CSV reads, including path and stream inputs, bounded ranges, named tables, workbook +> metadata, and managed `DataTable`/`IDataReader` adapters. ## Install @@ -53,6 +54,27 @@ 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 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 csvRows = MiniExcelRust.QueryCsv( + "input.csv", + useHeaderRow: true, + new MiniExcelRustCsvReadOptions { Delimiter = ';' }); +``` + +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 +105,18 @@ 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 +``` + `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 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..07af4bb --- /dev/null +++ b/docs/parity-matrix.md @@ -0,0 +1,57 @@ +# 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` | Partial | Ignore missing rows and header behavior tested; merged-fill semantics differ | +| 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 and normal declared OOXML dimensions | +| 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` | Partial | Single selected sheet and materialized rows; no `NextResult` yet | +| Async | Metadata tasks | Partial | Runs Rust operation on a worker; no in-flight native cancellation | +| Async | `IAsyncEnumerable` query | Missing | Requires cancellable native iterator and netstandard async interfaces | +| Typed read | POCO/attribute mapping | Missing | Requires schema/mapping plan and conversion parity | +| Comments | Notes/threaded comments | Missing | Rust core exists; ABI and managed models required | +| 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 | +| CSV write | Export/append | Missing | Rust core exists; writer ABI required | +| XLSX write | Export/multi-sheet | Missing | Rust core exists; input schema/callback ABI required | +| Workbook edits | Insert/copy/alter | Missing | Rust core is partial; package-preservation tests required | +| Templates | Fill/merge | Missing | Rust core is partial; formula and relationship parity required | +| Pictures | AddPicture | Missing | Rust core implementation required | +| Fluent mapping | Read/write/template | Missing | Managed mapping plan plus Rust execution required | +| Legacy facade | `MiniExcelLibs.MiniExcel` | Missing | Must be added after behavior-level APIs stabilize | + +## Confirmed differences + +1. `FillMergedCells`: C# fills only explicitly represented empty cells in a merged range; Rust 0.4 + synthesizes cells that are absent from worksheet XML. +2. Self-closing empty row: C# currently emits `` even with `IgnoreEmptyRows=true`; Rust skips it. +3. Missing ``: C# reports an empty range; Rust scans cells and returns the actual range. +4. 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/src/lib.rs b/native/miniexcel-ffi/src/lib.rs index 93413d4..4acf0da 100644 --- a/native/miniexcel-ffi/src/lib.rs +++ b/native/miniexcel-ffi/src/lib.rs @@ -4,7 +4,10 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::ptr; use std::str::FromStr; -use miniexcel::{CellReference, CellValue, DynamicRow, HeaderMode, MiniExcel, ReadOptions}; +use miniexcel::{ + CellReference, CellValue, CsvConfiguration, CsvEncoding, CsvReadOptions, DynamicRow, + HeaderMode, MiniExcel, ReadOptions, SheetType, SheetVisibility, +}; const ABI_VERSION: u32 = 1; const RESULT_END: i32 = 0; @@ -22,6 +25,24 @@ pub struct QueryHandle { frame: Vec, } +pub struct BufferHandle { + frame: Vec, +} + +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, +} + #[unsafe(no_mangle)] pub extern "C" fn miniexcel_abi_version() -> u32 { ABI_VERSION @@ -40,36 +61,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 +251,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,6 +369,254 @@ pub unsafe extern "C" fn miniexcel_query_close(handle: *mut QueryHandle) { } } +/// Returns worksheet names in workbook order using memory owned by an opaque buffer handle. +/// +/// # Safety +/// +/// `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_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); + } + + 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 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) + }) +} + +/// 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); + } + + 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); + } + } + + 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 = MiniExcel::get_sheet_dimensions(path).map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let mut frame = Vec::new(); + write_length(&mut frame, dimensions.len())?; + for dimension in dimensions { + write_string( + &mut frame, + dimension + .start_cell() + .map(|cell| cell.to_string()) + .unwrap_or_default(), + )?; + write_string( + &mut frame, + dimension + .end_cell() + .map(|cell| cell.to_string()) + .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) + }) +} + +/// 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 @@ -182,6 +652,84 @@ unsafe fn read_utf8<'a>(value: *const c_char) -> Result<&'a str, i32> { }) } +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 = 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) + .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); + + if !end_cell.is_null() { + let end_cell = unsafe { read_utf8(end_cell) }?; + let end_cell = CellReference::from_str(end_cell).map_err(|error| { + set_last_error(error.to_string()); + ERROR_INVALID_ARGUMENT + })?; + options = options.with_end_cell(end_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); + } + } + + 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 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(); @@ -244,6 +792,51 @@ fn write_string(frame: &mut Vec, value: impl AsRef) -> Result<(), i32> 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 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 = match encoding { + 0 => CsvEncoding::Utf8, + 1 => CsvEncoding::Utf16Le, + 2 => CsvEncoding::Utf16Be, + 3 => CsvEncoding::Gbk, + 4 => CsvEncoding::Windows1252, + _ => { + set_last_error("encoding is not supported"); + return Err(ERROR_INVALID_ARGUMENT); + } + }; + 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 write_length(frame: &mut Vec, length: usize) -> Result<(), i32> { let length = u32::try_from(length).map_err(|_| { set_last_error("FFI frame value exceeds the 4 GiB format limit"); @@ -279,4 +872,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/MiniExcelRust.cs b/src/MiniExcelRust/MiniExcelRust.cs index 01d0660..20025c0 100644 --- a/src/MiniExcelRust/MiniExcelRust.cs +++ b/src/MiniExcelRust/MiniExcelRust.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Data; using System.Globalization; using System.Runtime.InteropServices; using System.Text; @@ -13,6 +14,272 @@ public static class MiniExcelRust { private const int BatchSize = 64; + /// + /// Returns worksheet names in workbook order. + /// + 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, + string startCell = "A1") + { + 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)); + + 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); + } + + /// + /// 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 = 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.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); + } + + /// + /// 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) + { + return QueryAsDataTable(path, hasHeaderRow, sheetName, startCell, configuration).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) + { + var table = QueryAsDataTable(stream, hasHeaderRow, sheetName, startCell, configuration, leaveOpen); + return table.CreateDataReader(); + } + /// /// Streams rows from an XLSX file through the native Rust query engine. /// @@ -20,40 +287,435 @@ public static class MiniExcelRust string path, bool useHeaderRow = false, string? sheetName = null, - string startCell = "A1") + 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); + return QueryIterator(Path.GetFullPath(path), useHeaderRow, sheetName, startCell, null, configuration); } - private static IEnumerable> QueryIterator( + /// + /// 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); + } + + /// + /// 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(); + } + + private static IEnumerable> QueryStreamIterator( + Stream stream, bool useHeaderRow, string? sheetName, - string startCell) + 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 nativeStartCell = new Utf8String(startCell); - var result = NativeMethods.QueryOpen( + using var nativeTableName = new Utf8String(tableName); + var result = NativeMethods.QueryTableOpen( nativePath.Pointer, - useHeaderRow ? (byte)1 : (byte)0, nativeSheetName.Pointer, - nativeStartCell.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 +745,128 @@ 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 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 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 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 DeleteTemporaryFile(string? path) + { + if (path is not null && File.Exists(path)) + File.Delete(path); + } + private static void EnsureAbiVersion() { var version = NativeMethods.GetAbiVersion(); @@ -154,7 +938,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 +1013,22 @@ 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 static class NativeMethods { private const string LibraryName = "miniexcel_ffi"; @@ -238,6 +1044,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 +1107,40 @@ 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_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/MiniExcelRustCsvReadOptions.cs b/src/MiniExcelRust/MiniExcelRustCsvReadOptions.cs new file mode 100644 index 0000000..86895b3 --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustCsvReadOptions.cs @@ -0,0 +1,24 @@ +namespace MiniExcelLibs; + +public enum MiniExcelRustCsvEncoding : byte +{ + Utf8, + Utf16Le, + Utf16Be, + Gbk, + Windows1252 +} + +/// +/// Configures Rust-backed CSV queries. +/// +public sealed class MiniExcelRustCsvReadOptions +{ + 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/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..3afedce --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustReadOptions.cs @@ -0,0 +1,19 @@ +namespace MiniExcelLibs; + +/// +/// Configures Rust-backed XLSX queries. +/// +public sealed class MiniExcelRustReadOptions +{ + 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/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..b900e20 100644 --- a/tests/MiniExcelRust.PackageTests/Program.cs +++ b/tests/MiniExcelRust.PackageTests/Program.cs @@ -1,9 +1,11 @@ using System.Diagnostics; +using System.Data; using System.Globalization; using System.IO.Compression; using System.Text; using System.Text.Json; using MiniExcelLib; +using MiniExcelLib.Csv; using MiniExcelLib.OpenXml; using MiniExcelLibs; using ManagedMiniExcel = MiniExcelLib.MiniExcel; @@ -26,10 +28,13 @@ static int RunSuite(int lifecycleIterations, int maxPrivateGrowthMb) { 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); + VerifyCsvParity(csvPath); VerifyLifecycle(workbookPath, lifecycleIterations, maxPrivateGrowthMb); Console.WriteLine("MiniExcelRust parity and lifecycle suite passed."); return 0; @@ -38,11 +43,143 @@ static int RunSuite(int lifecycleIterations, int maxPrivateGrowthMb) { if (File.Exists(workbookPath)) File.Delete(workbookPath); + if (File.Exists(csvPath)) + File.Delete(csvPath); } } +static void VerifyCsvParity(string path) +{ + var managedConfiguration = new CsvConfiguration + { + Seperator = ';' + }; + var rustConfiguration = new MiniExcelRustCsvReadOptions + { + Delimiter = ';' + }; + var importer = ManagedMiniExcel.Importers.GetCsvImporter(); + var managedRows = importer.Query(path, true, managedConfiguration) + .Cast>() + .ToList(); + var rustRows = MiniExcelRust.QueryCsv(path, true, rustConfiguration).ToList(); + CompareRows(managedRows, rustRows, "csv"); + Require(rustRows.Count == 2, $"csv: expected 2 rows, received {rustRows.Count}."); + Require(Equals(rustRows[1]["Note"], string.Empty), "csv: empty field should remain an empty string."); + + var managedColumns = importer.GetColumnNames(path, true, managedConfiguration); + var rustColumns = MiniExcelRust.GetCsvColumnNames(path, true, rustConfiguration); + Require(managedColumns.SequenceEqual(rustColumns, StringComparer.Ordinal), "csv-columns: values differ."); + var asyncRustColumns = MiniExcelRust.GetCsvColumnNamesAsync(path, true, rustConfiguration).GetAwaiter().GetResult(); + Require(managedColumns.SequenceEqual(asyncRustColumns, StringComparer.Ordinal), "csv-columns-async: values differ."); + + var managedTable = importer.QueryAsDataTable(path, true, managedConfiguration); + var rustTable = MiniExcelRust.QueryCsvAsDataTable(path, true, rustConfiguration); + CompareDataTables(managedTable, rustTable, "csv-data-table"); + + using var reader = MiniExcelRust.GetCsvReader(path, true, rustConfiguration); + var readerRows = 0; + while (reader.Read()) + readerRows++; + Require(readerRows == 2, $"csv-data-reader: expected 2 rows, received {readerRows}."); + + using var stream = new MemoryStream(File.ReadAllBytes(path)); + var streamRows = MiniExcelRust.QueryCsv(stream, true, rustConfiguration, leaveOpen: true).ToList(); + CompareRows(managedRows, streamRows, "csv-stream"); + Require(stream.CanRead, "csv-stream: leaveOpen should preserve the stream."); + + stream.Position = 0; + var streamColumns = MiniExcelRust.GetCsvColumnNames(stream, true, rustConfiguration, leaveOpen: true); + Require(managedColumns.SequenceEqual(streamColumns, StringComparer.Ordinal), "csv-columns-stream: values differ."); + Require(stream.CanRead, "csv-columns-stream: leaveOpen should preserve the stream."); +} + static void VerifyParity(string path) { + var importer = ManagedMiniExcel.Importers.GetOpenXmlImporter(); + var managedSheetNames = importer.GetSheetNames(path); + var rustSheetNames = MiniExcelRust.GetSheetNames(path); + Require( + managedSheetNames.SequenceEqual(rustSheetNames, StringComparer.Ordinal), + $"sheet-names: managed={string.Join(",", managedSheetNames)}, rust={string.Join(",", rustSheetNames)}."); + + var asyncRustSheetNames = MiniExcelRust.GetSheetNamesAsync(path).GetAwaiter().GetResult(); + Require( + managedSheetNames.SequenceEqual(asyncRustSheetNames, StringComparer.Ordinal), + "sheet-names-async: the Rust result differs from the managed baseline."); + + VerifyColumnNames(importer, path, true, "Sheet1", "A1"); + VerifyColumnNames(importer, path, false, "Data", "C2"); + var asyncRustColumns = MiniExcelRust.GetColumnNamesAsync(path, true, "Sheet1").GetAwaiter().GetResult(); + Require( + asyncRustColumns.SequenceEqual(new[] { "Name", "Value", "Note" }, StringComparer.Ordinal), + "column-names-async: the Rust result did not match the expected headers."); + + var managedDimensions = importer.GetSheetDimensions(path); + var rustDimensions = MiniExcelRust.GetSheetDimensions(path); + Require(managedDimensions.Count == rustDimensions.Count, "sheet-dimensions: sheet count differs."); + for (var index = 0; index < managedDimensions.Count; index++) + { + Require( + managedDimensions[index].StartCell == rustDimensions[index].StartCell && + managedDimensions[index].EndCell == rustDimensions[index].EndCell, + $"sheet-dimensions: range differs at sheet index {index}: " + + $"managed={managedDimensions[index].StartCell}:{managedDimensions[index].EndCell}, " + + $"rust={rustDimensions[index].StartCell}:{rustDimensions[index].EndCell}."); + } + var asyncRustDimensions = MiniExcelRust.GetSheetDimensionsAsync(path).GetAwaiter().GetResult(); + Require(asyncRustDimensions.Count == managedDimensions.Count, "sheet-dimensions-async: sheet count differs."); + + var managedSheetInfo = importer.GetSheetInformations(path); + var rustSheetInfo = MiniExcelRust.GetSheetInformations(path); + Require(managedSheetInfo.Count == rustSheetInfo.Count, "sheet-info: sheet count differs."); + for (var index = 0; index < managedSheetInfo.Count; index++) + { + Require(managedSheetInfo[index].Id == rustSheetInfo[index].Id, $"sheet-info: id differs at index {index}."); + Require(managedSheetInfo[index].Index == rustSheetInfo[index].Index, $"sheet-info: index differs at index {index}."); + Require(managedSheetInfo[index].Name == rustSheetInfo[index].Name, $"sheet-info: name differs at index {index}."); + Require(managedSheetInfo[index].State.ToString() == rustSheetInfo[index].State.ToString(), $"sheet-info: state differs at index {index}."); + Require(managedSheetInfo[index].Active == rustSheetInfo[index].Active, $"sheet-info: active state differs at index {index}."); + Require(rustSheetInfo[index].SheetType == MiniExcelRustSheetType.Worksheet, $"sheet-info: unexpected type at index {index}."); + } + var asyncRustSheetInfo = MiniExcelRust.GetSheetInformationsAsync(path).GetAwaiter().GetResult(); + Require(asyncRustSheetInfo.Count == managedSheetInfo.Count, "sheet-info-async: sheet count differs."); + + var managedRangeRows = QueryManagedRange(path, true, "Data", "C2", "D3").ToList(); + var rustRangeRows = MiniExcelRust.QueryRange(path, true, "Data", "C2", "D3").ToList(); + CompareRows(managedRangeRows, rustRangeRows, "bounded-range"); + Require(rustRangeRows.Count == 1, $"bounded-range: expected 1 row, received {rustRangeRows.Count}."); + + var managedTableRows = QueryManagedTable(path, "Data", "DataTable").ToList(); + var rustTableRows = MiniExcelRust.QueryTable(path, "Data", "datatable").ToList(); + CompareRows(managedTableRows, rustTableRows, "named-table"); + Require(rustTableRows.Count == 2, $"named-table: expected 2 rows, received {rustTableRows.Count}."); + + var managedConfiguration = new OpenXmlConfiguration + { + IgnoreEmptyRows = true, + TrimColumnNames = true + }; + var rustConfiguration = new MiniExcelRustReadOptions + { + IgnoreEmptyRows = true, + TrimColumnNames = true + }; + var managedConfiguredRows = QueryManaged( + path, + true, + "Options", + "A1", + managedConfiguration).ToList(); + var rustConfiguredRows = MiniExcelRust.Query( + path, + true, + "Options", + "A1", + rustConfiguration).ToList(); + CompareRows(managedConfiguredRows, rustConfiguredRows, "configured-query"); + Require(rustConfiguredRows.Count == 2, $"configured-query: expected 2 rows, received {rustConfiguredRows.Count}."); + var scenarios = new[] { new QueryScenario("header", true, "Sheet1", "A1"), @@ -65,6 +202,109 @@ static void VerifyParity(string path) Require(Equals(rows[1]["Name"], "beta"), "The second string value did not match."); Require(Equals(rows[1]["Value"], true), "The boolean value did not match."); Require(rows[1]["Note"] is null, "The empty value should be null."); + + var managedTable = importer.QueryAsDataTable(path, true, "Sheet1"); + var rustTable = MiniExcelRust.QueryAsDataTable(path, true, "Sheet1"); + CompareDataTables(managedTable, rustTable, "data-table"); + + using var reader = MiniExcelRust.GetReader(path, true, "Sheet1"); + Require(reader.FieldCount == 3, $"data-reader: expected 3 fields, received {reader.FieldCount}."); + var readerRows = 0; + while (reader.Read()) + readerRows++; + Require(readerRows == 3, $"data-reader: expected 3 rows, received {readerRows}."); + + VerifyStreamParity(path); +} + +static void CompareDataTables(DataTable expected, DataTable actual, string scenario) +{ + Require(expected.Columns.Count == actual.Columns.Count, $"{scenario}: column count differs."); + Require(expected.Rows.Count == actual.Rows.Count, $"{scenario}: row count differs."); + for (var column = 0; column < expected.Columns.Count; column++) + Require(expected.Columns[column].ColumnName == actual.Columns[column].ColumnName, $"{scenario}: column name differs at {column}."); + for (var row = 0; row < expected.Rows.Count; row++) + { + for (var column = 0; column < expected.Columns.Count; column++) + Require(Equals(expected.Rows[row][column], actual.Rows[row][column]), $"{scenario}: value differs at {row},{column}."); + } +} + +static void VerifyStreamParity(string path) +{ + var bytes = File.ReadAllBytes(path); + using (var stream = new MemoryStream(bytes)) + { + var names = MiniExcelRust.GetSheetNames(stream, leaveOpen: true); + Require(names.SequenceEqual(new[] { "Sheet1", "Data", "Options" }, StringComparer.Ordinal), "stream-sheet-names: values differ."); + Require(stream.CanRead, "stream-sheet-names: leaveOpen should preserve the stream."); + } + + using (var stream = new MemoryStream(bytes)) + { + var dimensions = MiniExcelRust.GetSheetDimensions(stream, leaveOpen: true); + Require(dimensions.Count == 3, "stream-sheet-dimensions: expected one range per sheet."); + Require(stream.CanRead, "stream-sheet-dimensions: leaveOpen should preserve the stream."); + } + + using (var stream = new MemoryStream(bytes)) + { + var sheetInfo = MiniExcelRust.GetSheetInformations(stream, leaveOpen: true); + Require(sheetInfo.Count == 3, "stream-sheet-info: expected one record per sheet."); + Require(stream.CanRead, "stream-sheet-info: leaveOpen should preserve the stream."); + } + + using (var stream = new MemoryStream(bytes)) + { + var expected = QueryManaged(path, true, "Data", "C2").ToList(); + var actual = MiniExcelRust.Query(stream, true, "Data", "C2", leaveOpen: true).ToList(); + CompareRows(expected, actual, "stream-query"); + Require(stream.CanRead, "stream-query: leaveOpen should preserve the stream."); + } + + using (var stream = new MemoryStream(bytes)) + { + var tableRows = MiniExcelRust.QueryTable(stream, "Data", "DataTable", leaveOpen: true).ToList(); + Require(tableRows.Count == 2, "stream-query-table: expected 2 rows."); + Require(stream.CanRead, "stream-query-table: leaveOpen should preserve the stream."); + } + + using (var stream = new MemoryStream(bytes)) + { + using var reader = MiniExcelRust.GetReader(stream, true, "Sheet1", leaveOpen: true); + Require(reader.Read(), "stream-data-reader: expected a row."); + Require(stream.CanRead, "stream-data-reader: leaveOpen should preserve the stream."); + } + + var closingStream = new MemoryStream(bytes); + _ = MiniExcelRust.GetColumnNames(closingStream, true, "Sheet1"); + Require(!closingStream.CanRead, "stream-column-names: the default should close the stream."); + + var earlyDisposalStream = new MemoryStream(bytes); + var enumerator = MiniExcelRust.Query(earlyDisposalStream).GetEnumerator(); + try + { + Require(enumerator.MoveNext(), "stream-query: expected a row before early disposal."); + } + finally + { + enumerator.Dispose(); + } + Require(!earlyDisposalStream.CanRead, "stream-query: early disposal should close the stream."); +} + +static void VerifyColumnNames( + OpenXmlImporter importer, + string path, + bool useHeaderRow, + string sheetName, + string startCell) +{ + var managedColumns = importer.GetColumnNames(path, useHeaderRow, sheetName, startCell); + var rustColumns = MiniExcelRust.GetColumnNames(path, useHeaderRow, sheetName, startCell); + Require( + managedColumns.SequenceEqual(rustColumns, StringComparer.Ordinal), + $"column-names: managed={string.Join(",", managedColumns)}, rust={string.Join(",", rustColumns)}."); } static void Require(bool condition, string message) @@ -77,14 +317,43 @@ static void Require(bool condition, string message) string path, bool useHeaderRow, string? sheetName = null, - string startCell = "A1") + string startCell = "A1", + OpenXmlConfiguration? configuration = null) { var importer = ManagedMiniExcel.Importers.GetOpenXmlImporter(); foreach (IDictionary row in importer.Query( path, hasHeaderRow: useHeaderRow, sheetName: sheetName, - startCell: startCell)) + startCell: startCell, + configuration: configuration)) + yield return row; +} + +static IEnumerable> QueryManagedRange( + string path, + bool useHeaderRow, + string? sheetName, + string startCell, + string? endCell) +{ + var importer = ManagedMiniExcel.Importers.GetOpenXmlImporter(); + foreach (IDictionary row in importer.QueryRange( + path, + hasHeaderRow: useHeaderRow, + sheetName: sheetName, + startCell: startCell, + endCell: endCell)) + yield return row; +} + +static IEnumerable> QueryManagedTable( + string path, + string? sheetName, + string tableName) +{ + var importer = ManagedMiniExcel.Importers.GetOpenXmlImporter(); + foreach (IDictionary row in importer.QueryTable(path, sheetName, tableName)) yield return row; } @@ -300,6 +569,8 @@ static void CreateWorkbook(string path) + + """); AddEntry(archive, "_rels/.rels", """ @@ -311,7 +582,8 @@ static void CreateWorkbook(string path) AddEntry(archive, "xl/workbook.xml", """ - + + """); AddEntry(archive, "xl/_rels/workbook.xml.rels", """ @@ -319,11 +591,13 @@ static void CreateWorkbook(string path) + """); AddEntry(archive, "xl/worksheets/sheet1.xml", """ + NameValueNote alpha42Taiwan 台灣 @@ -334,12 +608,40 @@ static void CreateWorkbook(string path) """); AddEntry(archive, "xl/worksheets/sheet2.xml", """ - + + CodeAmount x3.5 y9 + + + """); + AddEntry(archive, "xl/worksheets/_rels/sheet2.xml.rels", """ + + + + + """); + AddEntry(archive, "xl/tables/table1.xml", """ + + + + + +
+ """); + AddEntry(archive, "xl/worksheets/sheet3.xml", """ + + + + + Left Right + merged + xy + + """); } From 5bcb0572254df5eb21a843d6c5bc44c11ae9b1d9 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sun, 6 Sep 2026 02:23:42 +0800 Subject: [PATCH 03/17] Add Rust-backed comments and dynamic XLSX/CSV writes --- README.md | 19 + docs/parity-matrix.md | 7 +- native/miniexcel-ffi/src/lib.rs | 414 +++++++++++++++++- src/MiniExcelRust/MiniExcelRust.cs | 367 ++++++++++++++++ src/MiniExcelRust/MiniExcelRustComments.cs | 102 +++++ .../MiniExcelRustCsvWriteOptions.cs | 17 + tests/MiniExcelRust.PackageTests/Program.cs | 179 ++++++++ 7 files changed, 1089 insertions(+), 16 deletions(-) create mode 100644 src/MiniExcelRust/MiniExcelRustComments.cs create mode 100644 src/MiniExcelRust/MiniExcelRustCsvWriteOptions.cs diff --git a/README.md b/README.md index 1337868..c427038 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,26 @@ 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" } }); ``` Stream overloads stage input to a temporary file so the Rust engine can retain its bounded-memory @@ -111,6 +123,13 @@ Use the local MiniExcel checkout as the read-only behavior oracle instead of the ./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. diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md index 07af4bb..f1a323f 100644 --- a/docs/parity-matrix.md +++ b/docs/parity-matrix.md @@ -32,12 +32,13 @@ limits; **Missing** has no production implementation yet. | Async | Metadata tasks | Partial | Runs Rust operation on a worker; no in-flight native cancellation | | Async | `IAsyncEnumerable` query | Missing | Requires cancellable native iterator and netstandard async interfaces | | Typed read | POCO/attribute mapping | Missing | Requires schema/mapping plan and conversion parity | -| Comments | Notes/threaded comments | Missing | Rust core exists; ABI and managed models required | +| 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 | -| CSV write | Export/append | Missing | Rust core exists; writer ABI required | -| XLSX write | Export/multi-sheet | Missing | Rust core exists; input schema/callback ABI required | +| CSV write | Dynamic save/append, path | Partial | Delimiter, BOM, header, overwrite and append verified; stream, typed and async remain | +| XLSX write | Dynamic single-sheet `SaveAs`, path/stream | Partial | Basic scalars and overwrite behavior verified; temporal values, schema, styles and multi-sheet remain | +| XLSX write | Typed/async/multi-sheet export | Missing | Rust core exists; input schema/callback ABI required | | Workbook edits | Insert/copy/alter | Missing | Rust core is partial; package-preservation tests required | | Templates | Fill/merge | Missing | Rust core is partial; formula and relationship parity required | | Pictures | AddPicture | Missing | Rust core implementation required | diff --git a/native/miniexcel-ffi/src/lib.rs b/native/miniexcel-ffi/src/lib.rs index 4acf0da..b800768 100644 --- a/native/miniexcel-ffi/src/lib.rs +++ b/native/miniexcel-ffi/src/lib.rs @@ -5,8 +5,9 @@ use std::ptr; use std::str::FromStr; use miniexcel::{ - CellReference, CellValue, CsvConfiguration, CsvEncoding, CsvReadOptions, DynamicRow, - HeaderMode, MiniExcel, ReadOptions, SheetType, SheetVisibility, + CellReference, CellValue, CommentPerson, CommentTimestamp, CsvConfiguration, CsvEncoding, + CsvReadOptions, CsvWriteOptions, DynamicRow, HeaderMode, MiniExcel, ReadOptions, SheetType, + SheetVisibility, WriteOptions, }; const ABI_VERSION: u32 = 1; @@ -15,6 +16,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()) }; @@ -43,6 +45,17 @@ struct QueryOpenOptions { 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, +} + #[unsafe(no_mangle)] pub extern "C" fn miniexcel_abi_version() -> u32 { ABI_VERSION @@ -605,6 +618,197 @@ pub unsafe extern "C" fn miniexcel_get_sheet_info( }) } +/// 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) + }) +} + +/// 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 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, + ) + }) +} + /// Releases a buffer returned by a metadata operation. /// /// # Safety @@ -801,6 +1005,32 @@ fn write_strings(values: Vec) -> Result, i32> { 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, @@ -812,17 +1042,7 @@ fn csv_read_options( set_last_error("delimiter must be a single-byte character"); return Err(ERROR_INVALID_ARGUMENT); } - let encoding = match encoding { - 0 => CsvEncoding::Utf8, - 1 => CsvEncoding::Utf16Le, - 2 => CsvEncoding::Utf16Be, - 3 => CsvEncoding::Gbk, - 4 => CsvEncoding::Windows1252, - _ => { - set_last_error("encoding is not supported"); - return Err(ERROR_INVALID_ARGUMENT); - } - }; + let encoding = parse_csv_encoding(encoding)?; let configuration = CsvConfiguration::new() .with_delimiter(delimiter) .with_encoding(encoding) @@ -837,6 +1057,174 @@ fn csv_read_options( .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 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()?), + tag => { + set_last_error(format!("input frame contains unsupported value tag {tag}")); + return Err(ERROR_INVALID_ARGUMENT); + } + }; + row.insert(name, value); + } + rows.push(row); + } + reader.ensure_complete()?; + Ok(rows) +} + +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> { let length = u32::try_from(length).map_err(|_| { set_last_error("FFI frame value exceeds the 4 GiB format limit"); diff --git a/src/MiniExcelRust/MiniExcelRust.cs b/src/MiniExcelRust/MiniExcelRust.cs index 20025c0..382c4da 100644 --- a/src/MiniExcelRust/MiniExcelRust.cs +++ b/src/MiniExcelRust/MiniExcelRust.cs @@ -216,6 +216,55 @@ public static Task> GetSheetInformationsAsync( return Task.Run(() => GetSheetInformations(path), cancellationToken); } + /// + /// 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, + nativeSheetName.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 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. /// @@ -539,6 +588,101 @@ public static IDataReader GetCsvReader( 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) + { + 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 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.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); + return checked((int)rowCount); + } + finally + { + frameHandle.Free(); + } + } + + /// + /// 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) + { + 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); + 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); + } + + /// + /// 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); + } + private static IEnumerable> QueryStreamIterator( Stream stream, bool useHeaderRow, @@ -792,6 +936,74 @@ private static List DecodeSheetInfo(byte[] frame) 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) @@ -811,6 +1023,115 @@ private static DataTable CreateDataTable( return table; } + private static byte[] EncodeRows(IEnumerable> rows) + { + var materializedRows = rows.ToList(); + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); + 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); + } + } + writer.Flush(); + return stream.ToArray(); + } + + 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; + default: + throw new NotSupportedException($"Values of type {value.GetType().FullName} are not supported by SaveAs yet."); + } + } + private static TResult UseStagedStream( Stream stream, bool leaveOpen, @@ -907,6 +1228,11 @@ public string ReadString() return value; } + public string? ReadOptionalString() + { + return ReadByte() == 0 ? null : ReadString(); + } + public object? ReadValue() { EnsureAvailable(1); @@ -1138,6 +1464,47 @@ internal static extern int GetSheetInfo( 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_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_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_buffer_close", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] internal static extern void BufferClose(IntPtr handle); 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/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/tests/MiniExcelRust.PackageTests/Program.cs b/tests/MiniExcelRust.PackageTests/Program.cs index b900e20..bce64da 100644 --- a/tests/MiniExcelRust.PackageTests/Program.cs +++ b/tests/MiniExcelRust.PackageTests/Program.cs @@ -18,6 +18,7 @@ "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), "verify" => VerifyFileParity(args), "generate" => GenerateBenchmarkWorkbook(args), "managed" => Benchmark(args, useRust: false), @@ -25,6 +26,78 @@ _ => Usage() }; +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"); @@ -35,6 +108,8 @@ static int RunSuite(int lifecycleIterations, int maxPrivateGrowthMb) File.WriteAllText(csvPath, "Name;Note\r\nalpha;\"Taiwan 台灣\"\r\nbeta;\r\n", new UTF8Encoding(true)); VerifyParity(workbookPath); VerifyCsvParity(csvPath); + VerifySaveAs(); + VerifyCsvWrite(); VerifyLifecycle(workbookPath, lifecycleIterations, maxPrivateGrowthMb); Console.WriteLine("MiniExcelRust parity and lifecycle suite passed."); return 0; @@ -48,6 +123,109 @@ static int RunSuite(int lifecycleIterations, int maxPrivateGrowthMb) } } +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 }, + new Dictionary { ["Name"] = "beta", ["Value"] = null, ["Enabled"] = false } + }; + try + { + var written = MiniExcelRust.SaveAs(path, rows, sheetName: "Exported"); + Require(written == rows.Count, $"save-as: expected {rows.Count} written rows, received {written}."); + var managedRows = QueryManaged(path, true, "Exported").ToList(); + var rustRows = MiniExcelRust.Query(path, true, "Exported").ToList(); + CompareRows(managedRows, rustRows, "save-as-roundtrip"); + CompareRows(rows, rustRows, "save-as-input"); + + var rejectedExistingFile = false; + try + { + MiniExcelRust.SaveAs(path, rows); + } + catch (InvalidOperationException) + { + rejectedExistingFile = true; + } + Require(rejectedExistingFile, "save-as: overwrite=false should reject an existing file."); + + 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(rows, 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 VerifyCsvWrite() +{ + var path = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-write-{Guid.NewGuid():N}.csv"); + var initialRows = new List> + { + new Dictionary { ["Name"] = "alpha", ["Value"] = 42d }, + new Dictionary { ["Name"] = "台灣", ["Value"] = string.Empty } + }; + var appendedRows = new List> + { + new Dictionary { ["Name"] = "omega", ["Value"] = -7.5d } + }; + var writeOptions = new MiniExcelRustCsvWriteOptions { Delimiter = ';' }; + var readOptions = new MiniExcelRustCsvReadOptions { Delimiter = ';' }; + try + { + var written = MiniExcelRust.SaveAsCsv(path, initialRows, writeOptions); + Require(written == initialRows.Count, "csv-write: initial row count differs."); + + var rejectedExistingFile = false; + try + { + MiniExcelRust.SaveAsCsv(path, initialRows, writeOptions); + } + catch (InvalidOperationException) + { + rejectedExistingFile = true; + } + Require(rejectedExistingFile, "csv-write: overwrite=false should reject an existing file."); + + written = MiniExcelRust.AppendCsv(path, appendedRows, writeOptions); + Require(written == appendedRows.Count, "csv-append: appended row count differs."); + + var importer = ManagedMiniExcel.Importers.GetCsvImporter(); + var managedRows = importer.Query(path, true, new CsvConfiguration { Seperator = ';' }) + .Cast>() + .ToList(); + var rustRows = MiniExcelRust.QueryCsv(path, true, readOptions).ToList(); + CompareRows(managedRows, rustRows, "csv-write-roundtrip"); + Require(rustRows.Count == 3, $"csv-write-roundtrip: expected 3 rows, received {rustRows.Count}."); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } +} + static void VerifyCsvParity(string path) { var managedConfiguration = new CsvConfiguration @@ -711,6 +889,7 @@ static int Usage() { Console.Error.WriteLine("Usage:"); Console.Error.WriteLine(" PublicNuGetSmoke suite [lifecycle-iterations] [max-private-growth-mb]"); + Console.Error.WriteLine(" PublicNuGetSmoke comments [sheet-name]"); Console.Error.WriteLine(" PublicNuGetSmoke verify [use-header-row]"); Console.Error.WriteLine(" PublicNuGetSmoke generate [rows] [columns]"); Console.Error.WriteLine(" PublicNuGetSmoke [passes] [warmup-passes]"); From f850d4c3e7a1e44c74e52375f6e89646b4a8c771 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sun, 6 Sep 2026 02:36:02 +0800 Subject: [PATCH 04/17] Add typed queries and worksheet mutation APIs --- README.md | 2 + docs/parity-matrix.md | 5 +- native/miniexcel-ffi/src/lib.rs | 88 +++++++ src/MiniExcelRust/MiniExcelRust.cs | 114 +++++++++ src/MiniExcelRust/MiniExcelRustMapper.cs | 236 ++++++++++++++++++ .../MiniExcelRustMappingException.cs | 23 ++ tests/MiniExcelRust.PackageTests/Program.cs | 141 ++++++++++- 7 files changed, 606 insertions(+), 3 deletions(-) create mode 100644 src/MiniExcelRust/MiniExcelRustMapper.cs create mode 100644 src/MiniExcelRust/MiniExcelRustMappingException.cs diff --git a/README.md b/README.md index c427038..c9a7064 100644 --- a/README.md +++ b/README.md @@ -36,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 diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md index f1a323f..87e58f2 100644 --- a/docs/parity-matrix.md +++ b/docs/parity-matrix.md @@ -31,7 +31,7 @@ limits; **Missing** has no production implementation yet. | Adapters | `GetReader` | Partial | Single selected sheet and materialized rows; no `NextResult` yet | | Async | Metadata tasks | Partial | Runs Rust operation on a worker; no in-flight native cancellation | | Async | `IAsyncEnumerable` query | Missing | Requires cancellable native iterator and netstandard async interfaces | -| Typed read | POCO/attribute mapping | Missing | Requires schema/mapping plan and conversion parity | +| Typed read | POCO/attribute mapping | Partial | Properties, name aliases, GUID, enum, integer and nullable conversions verified; full attributes/culture/errors remain | | 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 | @@ -39,7 +39,8 @@ limits; **Missing** has no production implementation yet. | CSV write | Dynamic save/append, path | Partial | Delimiter, BOM, header, overwrite and append verified; stream, typed and async remain | | XLSX write | Dynamic single-sheet `SaveAs`, path/stream | Partial | Basic scalars and overwrite behavior verified; temporal values, schema, styles and multi-sheet remain | | XLSX write | Typed/async/multi-sheet export | Missing | Rust core exists; input schema/callback ABI required | -| Workbook edits | Insert/copy/alter | Missing | Rust core is partial; package-preservation tests required | +| Workbook edits | Rename/reorder/visibility | Verified | Atomic path operations checked through C# and Rust metadata readers | +| Workbook edits | Insert/copy-and-add | Missing | Row/schema ABI and package-preservation tests required | | Templates | Fill/merge | Missing | Rust core is partial; formula and relationship parity required | | Pictures | AddPicture | Missing | Rust core implementation required | | Fluent mapping | Read/write/template | Missing | Managed mapping plan plus Rust execution required | diff --git a/native/miniexcel-ffi/src/lib.rs b/native/miniexcel-ffi/src/lib.rs index b800768..cb20e63 100644 --- a/native/miniexcel-ffi/src/lib.rs +++ b/native/miniexcel-ffi/src/lib.rs @@ -809,6 +809,94 @@ pub unsafe extern "C" fn miniexcel_append_csv( }) } +/// 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 diff --git a/src/MiniExcelRust/MiniExcelRust.cs b/src/MiniExcelRust/MiniExcelRust.cs index 382c4da..ab7406a 100644 --- a/src/MiniExcelRust/MiniExcelRust.cs +++ b/src/MiniExcelRust/MiniExcelRust.cs @@ -14,6 +14,62 @@ public static class MiniExcelRust { private const int BatchSize = 64; + 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)); + } + + 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)); + } + + 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)); + } + + 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 QueryCsv( + string path, + bool treatHeaderAsData = false, + MiniExcelRustCsvReadOptions? configuration = null) + where T : class, new() + { + return MiniExcelRustMapper.Map(QueryCsv(path, !treatHeaderAsData, configuration)); + } + /// /// Returns worksheet names in workbook order. /// @@ -683,6 +739,47 @@ public static int AppendCsv( return WriteCsv(path, rows, configuration, append: true); } + 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); + } + private static IEnumerable> QueryStreamIterator( Stream stream, bool useHeaderRow, @@ -1182,6 +1279,14 @@ private static void ValidateCsvConfiguration(MiniExcelRustCsvReadOptions? config 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 void DeleteTemporaryFile(string? path) { if (path is not null && File.Exists(path)) @@ -1505,6 +1610,15 @@ internal static extern int AppendCsv( 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_buffer_close", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] internal static extern void BufferClose(IntPtr handle); diff --git a/src/MiniExcelRust/MiniExcelRustMapper.cs b/src/MiniExcelRust/MiniExcelRustMapper.cs new file mode 100644 index 0000000..1804892 --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustMapper.cs @@ -0,0 +1,236 @@ +using System.ComponentModel; +using System.Globalization; +using System.Reflection; + +namespace MiniExcelLibs; + +internal static class MiniExcelRustMapper +{ + public static IEnumerable Map(IEnumerable> rows) + where T : class, new() + { + var mappings = CreateMappings(typeof(T)); + 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) + continue; + + try + { + mapping.SetValue(instance, ConvertValue(value, mapping.ValueType)); + } + 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) + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public; + var members = type.GetProperties(flags) + .Where(property => property.SetMethod is not null) + .Cast() + .Concat(type.GetFields(flags).Where(HasMiniExcelAttribute)); + return members + .Where(member => !IsIgnored(member)) + .Select(CreateMapping) + .ToList(); + } + + private static MemberMapping CreateMapping(MemberInfo member) + { + var names = new List { member.Name }; + int? index = 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); + } + 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; + } + } + + var valueType = member is PropertyInfo property ? property.PropertyType : ((FieldInfo)member).FieldType; + return new MemberMapping(member, names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), index, valueType); + } + + 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) + { + 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, 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(DateTime)) + return value is double serial ? DateTime.FromOADate(serial) : Convert.ToDateTime(value, CultureInfo.InvariantCulture); + if (effectiveType == typeof(DateTimeOffset)) + return DateTimeOffset.Parse(Convert.ToString(value, CultureInfo.InvariantCulture)!, CultureInfo.InvariantCulture); + if (effectiveType == typeof(TimeSpan)) + return value is double milliseconds + ? TimeSpan.FromMilliseconds(milliseconds) + : TimeSpan.Parse(Convert.ToString(value, CultureInfo.InvariantCulture)!, CultureInfo.InvariantCulture); + if (effectiveType == typeof(bool)) + { + var text = Convert.ToString(value, CultureInfo.InvariantCulture); + return text switch { "1" => true, "0" => false, _ => bool.Parse(text!) }; + } + if (effectiveType.IsEnum) + { + var text = Convert.ToString(value, CultureInfo.InvariantCulture)!; + var described = effectiveType.GetFields() + .FirstOrDefault(field => field.GetCustomAttribute()?.Description == text); + return Enum.Parse(effectiveType, described?.Name ?? text, ignoreCase: true); + } + return Convert.ChangeType(value, effectiveType, CultureInfo.InvariantCulture); + } + + 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)); + + 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) + { + 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 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) + { + public string[] Names { get; } = names; + public int? Index { get; } = index; + public Type ValueType { get; } = valueType; + + public void SetValue(object target, object? value) + { + if (member is PropertyInfo property) + property.SetValue(target, value); + else + ((FieldInfo)member).SetValue(target, 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..b8ee9fe --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustMappingException.cs @@ -0,0 +1,23 @@ +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; } +} \ No newline at end of file diff --git a/tests/MiniExcelRust.PackageTests/Program.cs b/tests/MiniExcelRust.PackageTests/Program.cs index bce64da..cc22c92 100644 --- a/tests/MiniExcelRust.PackageTests/Program.cs +++ b/tests/MiniExcelRust.PackageTests/Program.cs @@ -8,6 +8,7 @@ using MiniExcelLib.Csv; using MiniExcelLib.OpenXml; using MiniExcelLibs; +using MiniExcelLibs.Attributes; using ManagedMiniExcel = MiniExcelLib.MiniExcel; if (args.Length == 0) @@ -110,6 +111,8 @@ static int RunSuite(int lifecycleIterations, int maxPrivateGrowthMb) VerifyCsvParity(csvPath); VerifySaveAs(); VerifyCsvWrite(); + VerifyTypedConversions(); + VerifyWorkbookMutations(workbookPath); VerifyLifecycle(workbookPath, lifecycleIterations, maxPrivateGrowthMb); Console.WriteLine("MiniExcelRust parity and lifecycle suite passed."); return 0; @@ -226,6 +229,66 @@ static void VerifyCsvWrite() } } +static void VerifyTypedConversions() +{ + var path = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-typed-{Guid.NewGuid():N}.csv"); + var identifier = Guid.Parse("1ad46df8-08df-4ca6-8528-f79068fc23ea"); + try + { + File.WriteAllText( + path, + $"Identifier,State,Count,Optional\r\n{identifier},Ready,12,\r\n", + new UTF8Encoding(true)); + var managedConfiguration = new CsvConfiguration { ReadEmptyStringAsNull = true }; + var rustConfiguration = new MiniExcelRustCsvReadOptions { ReadEmptyStringAsNull = true }; + var importer = ManagedMiniExcel.Importers.GetCsvImporter(); + var managed = importer.Query(path, configuration: managedConfiguration).Single(); + var rust = MiniExcelRust.QueryCsv(path, configuration: rustConfiguration).Single(); + Require(managed.Identifier == rust.Identifier && rust.Identifier == identifier, "typed-conversion: GUID differs."); + Require(managed.State == rust.State && rust.State == RowState.Ready, "typed-conversion: enum differs."); + Require(managed.Count == rust.Count && rust.Count == 12, "typed-conversion: integer differs."); + Require(managed.Optional == rust.Optional && rust.Optional is null, "typed-conversion: nullable differs."); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } +} + +static void VerifyWorkbookMutations(string sourcePath) +{ + var path = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-mutate-{Guid.NewGuid():N}.xlsx"); + File.Copy(sourcePath, path); + try + { + MiniExcelRust.RenameSheet(path, "Data", "Archive"); + MiniExcelRust.ReorderSheet(path, "Archive", 0); + MiniExcelRust.SetSheetVisibility(path, "Sheet1", MiniExcelRustSheetState.VeryHidden); + + var importer = ManagedMiniExcel.Importers.GetOpenXmlImporter(); + var managedNames = importer.GetSheetNames(path); + var rustNames = MiniExcelRust.GetSheetNames(path); + Require(managedNames.SequenceEqual(rustNames, StringComparer.Ordinal), "sheet-mutation: names differ."); + Require(rustNames.SequenceEqual(new[] { "Archive", "Sheet1", "Options" }, StringComparer.Ordinal), "sheet-mutation: order differs."); + + var managedInfo = importer.GetSheetInformations(path); + var rustInfo = MiniExcelRust.GetSheetInformations(path); + Require(managedInfo.Count == rustInfo.Count, "sheet-mutation: info count differs."); + for (var index = 0; index < managedInfo.Count; index++) + { + Require(managedInfo[index].Name == rustInfo[index].Name, $"sheet-mutation: name differs at {index}."); + Require(managedInfo[index].State.ToString() == rustInfo[index].State.ToString(), $"sheet-mutation: state differs at {index}."); + } + Require(rustInfo.Single(sheet => sheet.Name == "Sheet1").State == MiniExcelRustSheetState.VeryHidden, "sheet-mutation: visibility was not updated."); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } +} + static void VerifyCsvParity(string path) { var managedConfiguration = new CsvConfiguration @@ -245,6 +308,15 @@ static void VerifyCsvParity(string path) Require(rustRows.Count == 2, $"csv: expected 2 rows, received {rustRows.Count}."); Require(Equals(rustRows[1]["Note"], string.Empty), "csv: empty field should remain an empty string."); + var managedTypedRows = importer.Query(path, configuration: managedConfiguration).ToList(); + var rustTypedRows = MiniExcelRust.QueryCsv(path, configuration: rustConfiguration).ToList(); + Require(managedTypedRows.Count == rustTypedRows.Count, "csv-typed: row count differs."); + for (var index = 0; index < managedTypedRows.Count; index++) + { + Require(managedTypedRows[index].Name == rustTypedRows[index].Name, $"csv-typed: name differs at {index}."); + Require(managedTypedRows[index].Note == rustTypedRows[index].Note, $"csv-typed: note differs at {index}."); + } + var managedColumns = importer.GetColumnNames(path, true, managedConfiguration); var rustColumns = MiniExcelRust.GetCsvColumnNames(path, true, rustConfiguration); Require(managedColumns.SequenceEqual(rustColumns, StringComparer.Ordinal), "csv-columns: values differ."); @@ -381,6 +453,20 @@ static void VerifyParity(string path) Require(Equals(rows[1]["Value"], true), "The boolean value did not match."); Require(rows[1]["Note"] is null, "The empty value should be null."); + var managedTypedRows = importer.Query(path, "Sheet1").ToList(); + var rustTypedRows = MiniExcelRust.Query(path, "Sheet1").ToList(); + CompareTypedRows(managedTypedRows, rustTypedRows, "typed-query"); + var managedAliases = importer.Query(path, "Sheet1").ToList(); + var rustAliases = MiniExcelRust.Query(path, "Sheet1").ToList(); + Require( + managedAliases.Select(row => row.Label).SequenceEqual(rustAliases.Select(row => row.Label)), + "typed-alias: values differ."); + + var typedTableRows = MiniExcelRust.QueryTable(path, "Data", "DataTable").ToList(); + Require( + typedTableRows.Count == 2 && typedTableRows[0].Code == "x" && typedTableRows[0].Amount == 3.5d, + "typed-table: values differ."); + var managedTable = importer.QueryAsDataTable(path, true, "Sheet1"); var rustTable = MiniExcelRust.QueryAsDataTable(path, true, "Sheet1"); CompareDataTables(managedTable, rustTable, "data-table"); @@ -408,6 +494,20 @@ static void CompareDataTables(DataTable expected, DataTable actual, string scena } } +static void CompareTypedRows( + IReadOnlyList expected, + IReadOnlyList actual, + string scenario) +{ + Require(expected.Count == actual.Count, $"{scenario}: row count differs."); + for (var index = 0; index < expected.Count; index++) + { + Require(expected[index].Name == actual[index].Name, $"{scenario}: name differs at {index}."); + Require(Equals(expected[index].Value, actual[index].Value), $"{scenario}: value differs at {index}."); + Require(expected[index].Note == actual[index].Note, $"{scenario}: note differs at {index}."); + } +} + static void VerifyStreamParity(string path) { var bytes = File.ReadAllBytes(path); @@ -908,4 +1008,43 @@ internal sealed record BenchmarkResult( long Cells, double ElapsedMilliseconds, double FirstRowMilliseconds, - long AllocatedBytes); \ No newline at end of file + long AllocatedBytes); + + internal sealed class TypedSheetRow + { + public string? Name { get; set; } + public object? Value { get; set; } + public string? Note { get; set; } + } + + internal sealed class TypedAliasRow + { + [ExcelColumnName("Name")] + public string? Label { get; set; } + } + + internal sealed class TypedTableRow + { + public string? Code { get; set; } + public double Amount { get; set; } + } + + internal sealed class TypedCsvRow + { + public string? Name { get; set; } + public string? Note { get; set; } + } + + internal sealed class TypedConversionRow + { + public Guid Identifier { get; set; } + public RowState State { get; set; } + public int Count { get; set; } + public int? Optional { get; set; } + } + + internal enum RowState + { + Unknown, + Ready + } \ No newline at end of file From 1f4e79284023616229189cee269050edc7c5cf48 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sun, 6 Sep 2026 02:44:16 +0800 Subject: [PATCH 05/17] Add Rust-backed worksheet insert and copy APIs --- docs/parity-matrix.md | 2 +- native/miniexcel-ffi/src/lib.rs | 127 +++++++++++++++++- src/MiniExcelRust/MiniExcelRust.cs | 104 ++++++++++++++ .../MiniExcelRustInsertOptions.cs | 15 +++ tests/MiniExcelRust.PackageTests/Program.cs | 55 ++++++++ 5 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 src/MiniExcelRust/MiniExcelRustInsertOptions.cs diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md index 87e58f2..c34e9b3 100644 --- a/docs/parity-matrix.md +++ b/docs/parity-matrix.md @@ -40,7 +40,7 @@ limits; **Missing** has no production implementation yet. | XLSX write | Dynamic single-sheet `SaveAs`, path/stream | Partial | Basic scalars and overwrite behavior verified; temporal values, schema, styles and multi-sheet remain | | XLSX write | Typed/async/multi-sheet export | Missing | Rust core exists; input schema/callback ABI required | | Workbook edits | Rename/reorder/visibility | Verified | Atomic path operations checked through C# and Rust metadata readers | -| Workbook edits | Insert/copy-and-add | Missing | Row/schema ABI and package-preservation tests required | +| Workbook edits | Dynamic insert/copy-and-add, path | Partial | Add/reject/replace/source preservation verified; stream and complex relationship policies remain | | Templates | Fill/merge | Missing | Rust core is partial; formula and relationship parity required | | Pictures | AddPicture | Missing | Rust core implementation required | | Fluent mapping | Read/write/template | Missing | Managed mapping plan plus Rust execution required | diff --git a/native/miniexcel-ffi/src/lib.rs b/native/miniexcel-ffi/src/lib.rs index cb20e63..058f768 100644 --- a/native/miniexcel-ffi/src/lib.rs +++ b/native/miniexcel-ffi/src/lib.rs @@ -6,8 +6,8 @@ use std::str::FromStr; use miniexcel::{ CellReference, CellValue, CommentPerson, CommentTimestamp, CsvConfiguration, CsvEncoding, - CsvReadOptions, CsvWriteOptions, DynamicRow, HeaderMode, MiniExcel, ReadOptions, SheetType, - SheetVisibility, WriteOptions, + CsvReadOptions, CsvWriteOptions, DynamicRow, ExistingSheetPolicy, HeaderMode, InsertOptions, + MiniExcel, ReadOptions, SheetType, SheetVisibility, TargetRelationshipPolicy, WriteOptions, }; const ABI_VERSION: u32 = 1; @@ -809,6 +809,97 @@ pub unsafe extern "C" fn miniexcel_append_csv( }) } +/// 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) + }) +} + /// Atomically renames a worksheet in an existing XLSX workbook. /// /// # Safety @@ -1239,6 +1330,38 @@ fn decode_rows(bytes: &[u8]) -> Result, i32> { Ok(rows) } +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, diff --git a/src/MiniExcelRust/MiniExcelRust.cs b/src/MiniExcelRust/MiniExcelRust.cs index ab7406a..376814c 100644 --- a/src/MiniExcelRust/MiniExcelRust.cs +++ b/src/MiniExcelRust/MiniExcelRust.cs @@ -780,6 +780,86 @@ public static void SetSheetVisibility( 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 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(); + } + } + private static IEnumerable> QueryStreamIterator( Stream stream, bool useHeaderRow, @@ -1619,6 +1699,30 @@ internal static extern int AppendCsv( [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_buffer_close", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] internal static extern void BufferClose(IntPtr handle); 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/tests/MiniExcelRust.PackageTests/Program.cs b/tests/MiniExcelRust.PackageTests/Program.cs index cc22c92..83ae542 100644 --- a/tests/MiniExcelRust.PackageTests/Program.cs +++ b/tests/MiniExcelRust.PackageTests/Program.cs @@ -112,6 +112,7 @@ static int RunSuite(int lifecycleIterations, int maxPrivateGrowthMb) VerifySaveAs(); VerifyCsvWrite(); VerifyTypedConversions(); + VerifyInsertAndCopy(); VerifyWorkbookMutations(workbookPath); VerifyLifecycle(workbookPath, lifecycleIterations, maxPrivateGrowthMb); Console.WriteLine("MiniExcelRust parity and lifecycle suite passed."); @@ -289,6 +290,60 @@ static void VerifyWorkbookMutations(string sourcePath) } } +static void VerifyInsertAndCopy() +{ + var insertPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-insert-{Guid.NewGuid():N}.xlsx"); + var destinationPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-copy-{Guid.NewGuid():N}.xlsx"); + var rows = new List> + { + new Dictionary { ["Key"] = "A", ["Amount"] = 10d }, + new Dictionary { ["Key"] = "B", ["Amount"] = 20d } + }; + try + { + MiniExcelRust.SaveAs( + insertPath, + new[] { new Dictionary { ["Seed"] = "value" } }, + sheetName: "Base"); + var inserted = MiniExcelRust.InsertSheet(insertPath, rows, "Inserted"); + Require(inserted == rows.Count, "insert-sheet: row count differs."); + CompareRows(rows, MiniExcelRust.Query(insertPath, true, "Inserted").ToList(), "insert-sheet"); + + var rejectedDuplicate = false; + try + { + MiniExcelRust.InsertSheet(insertPath, rows, "Inserted"); + } + catch (InvalidOperationException) + { + rejectedDuplicate = true; + } + Require(rejectedDuplicate, "insert-sheet: duplicate sheet should be rejected by default."); + + var replaced = MiniExcelRust.InsertSheet( + insertPath, + rows, + "Base", + new MiniExcelRustInsertOptions { ReplaceExistingSheet = true }); + Require(replaced == rows.Count, "insert-sheet: replacement row count differs."); + CompareRows(rows, QueryManaged(insertPath, true, "Base").ToList(), "replace-sheet"); + + var sourceHash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(insertPath))); + var copied = MiniExcelRust.CopyAndAddSheet(insertPath, destinationPath, rows, "Copied"); + Require(copied == rows.Count, "copy-add-sheet: row count differs."); + CompareRows(rows, QueryManaged(destinationPath, true, "Copied").ToList(), "copy-add-sheet"); + var sourceHashAfter = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(File.ReadAllBytes(insertPath))); + Require(sourceHash == sourceHashAfter, "copy-add-sheet: source workbook changed."); + } + finally + { + if (File.Exists(insertPath)) + File.Delete(insertPath); + if (File.Exists(destinationPath)) + File.Delete(destinationPath); + } +} + static void VerifyCsvParity(string path) { var managedConfiguration = new CsvConfiguration From 714c200c91203835d12820208f2f0e721753ed65 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sun, 6 Sep 2026 03:04:36 +0800 Subject: [PATCH 06/17] Add async queries and Rust-backed template operations --- Cargo.lock | 1 + README.md | 5 + docs/parity-matrix.md | 5 +- native/miniexcel-ffi/Cargo.toml | 1 + native/miniexcel-ffi/src/lib.rs | 70 ++++++++- src/MiniExcelRust/MiniExcelRust.cs | 146 +++++++++++++++++++ src/MiniExcelRust/MiniExcelRust.csproj | 4 + tests/MiniExcelRust.PackageTests/Program.cs | 150 ++++++++++++++++++++ 8 files changed, 379 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c25a165..e172310 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -428,6 +428,7 @@ name = "miniexcel-ffi" version = "0.1.0" dependencies = [ "miniexcel", + "serde_json", ] [[package]] diff --git a/README.md b/README.md index c9a7064..a440ba5 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,11 @@ var csvRows = MiniExcelRust.QueryCsv( MiniExcelRust.SaveAsCsv( "output.csv", new[] { new Dictionary { ["Name"] = "alpha" } }); + +MiniExcelRust.FillTemplate( + "report.xlsx", + "template.xlsx", + new { title = "Quarterly report", items = new[] { new { name = "Ada" } } }); ``` Stream overloads stage input to a temporary file so the Rust engine can retain its bounded-memory diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md index c34e9b3..771e704 100644 --- a/docs/parity-matrix.md +++ b/docs/parity-matrix.md @@ -30,7 +30,7 @@ limits; **Missing** has no production implementation yet. | Adapters | `QueryAsDataTable` | Verified | Single selected sheet; materialized managed adapter | | Adapters | `GetReader` | Partial | Single selected sheet and materialized rows; no `NextResult` yet | | Async | Metadata tasks | Partial | Runs Rust operation on a worker; no in-flight native cancellation | -| Async | `IAsyncEnumerable` query | Missing | Requires cancellable native iterator and netstandard async interfaces | +| Async | `IAsyncEnumerable` query | Partial | XLSX, typed, table and CSV enumeration plus pre-cancellation verified; in-flight native batch cancellation remains | | Typed read | POCO/attribute mapping | Partial | Properties, name aliases, GUID, enum, integer and nullable conversions verified; full attributes/culture/errors remain | | 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 | @@ -41,7 +41,8 @@ limits; **Missing** has no production implementation yet. | XLSX write | Typed/async/multi-sheet export | Missing | Rust core exists; input schema/callback ABI required | | 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 | Fill/merge | Missing | Rust core is partial; formula and relationship parity required | +| Templates | Path-to-path fill | Partial | Scalars, list expansion, strict missing variables and overwrite verified; stream/bytes combinations remain | +| Templates | `MergeSameCells`, path | Verified | Merge refs, marker removal, source preservation and overwrite verified | | Pictures | AddPicture | Missing | Rust core implementation required | | Fluent mapping | Read/write/template | Missing | Managed mapping plan plus Rust execution required | | Legacy facade | `MiniExcelLibs.MiniExcel` | Missing | Must be added after behavior-level APIs stabilize | diff --git a/native/miniexcel-ffi/Cargo.toml b/native/miniexcel-ffi/Cargo.toml index ae5a13e..aa2836d 100644 --- a/native/miniexcel-ffi/Cargo.toml +++ b/native/miniexcel-ffi/Cargo.toml @@ -14,3 +14,4 @@ crate-type = ["cdylib"] [dependencies] miniexcel = "=0.4.0" +serde_json = "1.0" diff --git a/native/miniexcel-ffi/src/lib.rs b/native/miniexcel-ffi/src/lib.rs index 058f768..b82e24d 100644 --- a/native/miniexcel-ffi/src/lib.rs +++ b/native/miniexcel-ffi/src/lib.rs @@ -7,7 +7,8 @@ use std::str::FromStr; use miniexcel::{ CellReference, CellValue, CommentPerson, CommentTimestamp, CsvConfiguration, CsvEncoding, CsvReadOptions, CsvWriteOptions, DynamicRow, ExistingSheetPolicy, HeaderMode, InsertOptions, - MiniExcel, ReadOptions, SheetType, SheetVisibility, TargetRelationshipPolicy, WriteOptions, + MergeSameCellsOptions, MiniExcel, ReadOptions, SheetType, SheetVisibility, + TargetRelationshipPolicy, TemplateOptions, WriteOptions, }; const ABI_VERSION: u32 = 1; @@ -900,6 +901,73 @@ pub unsafe extern "C" fn miniexcel_copy_and_add_sheet( }) } +/// 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) + }) +} + +/// 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) + }) +} + /// Atomically renames a worksheet in an existing XLSX workbook. /// /// # Safety diff --git a/src/MiniExcelRust/MiniExcelRust.cs b/src/MiniExcelRust/MiniExcelRust.cs index 376814c..5ede223 100644 --- a/src/MiniExcelRust/MiniExcelRust.cs +++ b/src/MiniExcelRust/MiniExcelRust.cs @@ -1,8 +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; @@ -14,6 +16,65 @@ 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> 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> QueryTableAsync( + string path, + string? sheetName = null, + string tableName = "Table1", + CancellationToken cancellationToken = default) + { + return ToAsyncEnumerable(QueryTable(path, sheetName, tableName), 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 IEnumerable Query( string path, string? sheetName = null, @@ -860,6 +921,64 @@ public static int CopyAndAddSheet( } } + 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(); + } + } + + 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); + } + private static IEnumerable> QueryStreamIterator( Stream stream, bool useHeaderRow, @@ -1200,6 +1319,18 @@ private static DataTable CreateDataTable( 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) { var materializedRows = rows.ToList(); @@ -1723,6 +1854,21 @@ internal static extern int CopyAndAddSheet( 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_merge_same_cells", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int MergeSameCells( + IntPtr destinationPath, + IntPtr sourcePath, + byte overwriteFile); + [DllImport(LibraryName, EntryPoint = "miniexcel_buffer_close", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] internal static extern void BufferClose(IntPtr handle); 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/tests/MiniExcelRust.PackageTests/Program.cs b/tests/MiniExcelRust.PackageTests/Program.cs index 83ae542..a34fc1b 100644 --- a/tests/MiniExcelRust.PackageTests/Program.cs +++ b/tests/MiniExcelRust.PackageTests/Program.cs @@ -4,6 +4,7 @@ using System.IO.Compression; using System.Text; using System.Text.Json; +using System.Xml.Linq; using MiniExcelLib; using MiniExcelLib.Csv; using MiniExcelLib.OpenXml; @@ -20,6 +21,7 @@ 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), @@ -27,6 +29,60 @@ _ => 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); + 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 archive = ZipFile.OpenRead(path); + var entry = archive.GetEntry("xl/worksheets/sheet1.xml") + ?? throw new InvalidDataException("The workbook has no first worksheet."); + using var stream = entry.Open(); + var document = XDocument.Load(stream); + 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) @@ -113,6 +169,7 @@ static int RunSuite(int lifecycleIterations, int maxPrivateGrowthMb) VerifyCsvWrite(); VerifyTypedConversions(); VerifyInsertAndCopy(); + VerifyTemplateFill(); VerifyWorkbookMutations(workbookPath); VerifyLifecycle(workbookPath, lifecycleIterations, maxPrivateGrowthMb); Console.WriteLine("MiniExcelRust parity and lifecycle suite passed."); @@ -344,6 +401,68 @@ static void VerifyInsertAndCopy() } } +static void VerifyTemplateFill() +{ + var templatePath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-template-{Guid.NewGuid():N}.xlsx"); + var managedPath = Path.Combine(Path.GetTempPath(), $"miniexcel-managed-template-{Guid.NewGuid():N}.xlsx"); + var rustPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-template-output-{Guid.NewGuid():N}.xlsx"); + var strictPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-template-strict-{Guid.NewGuid():N}.xlsx"); + try + { + MiniExcelRust.SaveAs( + templatePath, + new[] + { + new Dictionary { ["A"] = "{{title}}", ["B"] = "{{active}}" }, + new Dictionary { ["A"] = "{{items.name}}", ["B"] = "{{items.score}}" } + }, + printHeader: false); + var value = new + { + title = "Quarterly ", + active = true, + items = new[] { new { name = "Ada", score = 10 }, new { name = "Linus", score = 20 } } + }; + + ManagedMiniExcel.Templaters.GetOpenXmlTemplater().FillTemplate(managedPath, templatePath, value); + MiniExcelRust.FillTemplate(rustPath, templatePath, value); + CompareRows( + QueryManaged(managedPath, false).ToList(), + MiniExcelRust.Query(rustPath).ToList(), + "template-fill"); + + var rejectedOverwrite = false; + try + { + MiniExcelRust.FillTemplate(rustPath, templatePath, value); + } + catch (InvalidOperationException) + { + rejectedOverwrite = true; + } + Require(rejectedOverwrite, "template-fill: overwrite=false should reject an existing file."); + + var rejectedMissing = false; + try + { + MiniExcelRust.FillTemplate(strictPath, templatePath, new { title = "Missing items" }, ignoreMissingVariables: false); + } + catch (InvalidOperationException) + { + rejectedMissing = true; + } + Require(rejectedMissing, "template-fill: strict missing variables should fail."); + } + finally + { + foreach (var path in new[] { templatePath, managedPath, rustPath, strictPath }) + { + if (File.Exists(path)) + File.Delete(path); + } + } +} + static void VerifyCsvParity(string path) { var managedConfiguration = new CsvConfiguration @@ -371,6 +490,8 @@ static void VerifyCsvParity(string path) Require(managedTypedRows[index].Name == rustTypedRows[index].Name, $"csv-typed: name differs at {index}."); Require(managedTypedRows[index].Note == rustTypedRows[index].Note, $"csv-typed: note differs at {index}."); } + var asyncRows = CollectAsync(MiniExcelRust.QueryCsvAsync(path, true, rustConfiguration)).GetAwaiter().GetResult(); + CompareRows(managedRows, asyncRows, "csv-async"); var managedColumns = importer.GetColumnNames(path, true, managedConfiguration); var rustColumns = MiniExcelRust.GetCsvColumnNames(path, true, rustConfiguration); @@ -522,6 +643,26 @@ static void VerifyParity(string path) typedTableRows.Count == 2 && typedTableRows[0].Code == "x" && typedTableRows[0].Amount == 3.5d, "typed-table: values differ."); + var asyncRows = CollectAsync(MiniExcelRust.QueryAsync(path, true, "Sheet1")).GetAwaiter().GetResult(); + CompareRows(rows, asyncRows, "async-query"); + var asyncTypedRows = CollectAsync(MiniExcelRust.QueryAsync(path, "Sheet1")).GetAwaiter().GetResult(); + CompareTypedRows(managedTypedRows, asyncTypedRows, "async-typed-query"); + var asyncTableRows = CollectAsync(MiniExcelRust.QueryTableAsync(path, "Data", "DataTable")).GetAwaiter().GetResult(); + CompareRows(managedTableRows, asyncTableRows, "async-table-query"); + + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var cancelled = false; + try + { + _ = CollectAsync(MiniExcelRust.QueryAsync(path, cancellationToken: cancellation.Token)).GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + cancelled = true; + } + Require(cancelled, "async-query: a pre-cancelled token should cancel enumeration."); + var managedTable = importer.QueryAsDataTable(path, true, "Sheet1"); var rustTable = MiniExcelRust.QueryAsDataTable(path, true, "Sheet1"); CompareDataTables(managedTable, rustTable, "data-table"); @@ -563,6 +704,14 @@ static void CompareTypedRows( } } +static async Task> CollectAsync(IAsyncEnumerable values) +{ + var result = new List(); + await foreach (var value in values) + result.Add(value); + return result; +} + static void VerifyStreamParity(string path) { var bytes = File.ReadAllBytes(path); @@ -1045,6 +1194,7 @@ static int Usage() Console.Error.WriteLine("Usage:"); Console.Error.WriteLine(" PublicNuGetSmoke suite [lifecycle-iterations] [max-private-growth-mb]"); Console.Error.WriteLine(" PublicNuGetSmoke comments [sheet-name]"); + Console.Error.WriteLine(" PublicNuGetSmoke merge "); Console.Error.WriteLine(" PublicNuGetSmoke verify [use-header-row]"); Console.Error.WriteLine(" PublicNuGetSmoke generate [rows] [columns]"); Console.Error.WriteLine(" PublicNuGetSmoke [passes] [warmup-passes]"); From b3d8b249e7408d29758a8a7fc428b1b24dfb41f7 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sun, 6 Sep 2026 12:47:33 +0800 Subject: [PATCH 07/17] Expand CSV and template operations to streams and byte arrays --- docs/parity-matrix.md | 6 +- src/MiniExcelRust/MiniExcelRust.cs | 232 ++++++++++++++++++++ tests/MiniExcelRust.PackageTests/Program.cs | 79 ++++++- 3 files changed, 306 insertions(+), 11 deletions(-) diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md index 771e704..20614ef 100644 --- a/docs/parity-matrix.md +++ b/docs/parity-matrix.md @@ -36,13 +36,13 @@ limits; **Missing** has no production implementation yet. | 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 | -| CSV write | Dynamic save/append, path | Partial | Delimiter, BOM, header, overwrite and append verified; stream, typed and async remain | +| CSV write | Dynamic save/append, path/stream | Partial | Delimiter, BOM, header, overwrite and append verified; typed and async remain | | XLSX write | Dynamic single-sheet `SaveAs`, path/stream | Partial | Basic scalars and overwrite behavior verified; temporal values, schema, styles and multi-sheet remain | | XLSX write | Typed/async/multi-sheet export | Missing | Rust core exists; input schema/callback ABI required | | 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-to-path fill | Partial | Scalars, list expansion, strict missing variables and overwrite verified; stream/bytes combinations remain | -| Templates | `MergeSameCells`, path | Verified | Merge refs, marker removal, source preservation and overwrite verified | +| 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 | Missing | Rust core implementation required | | Fluent mapping | Read/write/template | Missing | Managed mapping plan plus Rust execution required | | Legacy facade | `MiniExcelLibs.MiniExcel` | Missing | Must be added after behavior-level APIs stabilize | diff --git a/src/MiniExcelRust/MiniExcelRust.cs b/src/MiniExcelRust/MiniExcelRust.cs index 5ede223..ecb6349 100644 --- a/src/MiniExcelRust/MiniExcelRust.cs +++ b/src/MiniExcelRust/MiniExcelRust.cs @@ -800,6 +800,68 @@ public static int AppendCsv( return WriteCsv(path, rows, configuration, append: true); } + 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 RenameSheet(string path, string sheetName, string newSheetName) { ValidatePathAndSheet(path, sheetName); @@ -958,6 +1020,96 @@ public static void FillTemplate( } } + 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, @@ -979,6 +1131,67 @@ public static void MergeSameCells( 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; + }); + } + private static IEnumerable> QueryStreamIterator( Stream stream, bool useHeaderRow, @@ -1484,6 +1697,25 @@ private static void ValidateReadableStream(Stream stream) 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)) diff --git a/tests/MiniExcelRust.PackageTests/Program.cs b/tests/MiniExcelRust.PackageTests/Program.cs index a34fc1b..414fcf6 100644 --- a/tests/MiniExcelRust.PackageTests/Program.cs +++ b/tests/MiniExcelRust.PackageTests/Program.cs @@ -60,6 +60,14 @@ static int VerifyMergeSameCells(string[] arguments) } 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; } @@ -72,11 +80,17 @@ static int VerifyMergeSameCells(string[] arguments) static List ReadMergeReferences(string path) { - using var archive = ZipFile.OpenRead(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 stream = entry.Open(); - var document = XDocument.Load(stream); + 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) @@ -279,6 +293,15 @@ static void VerifyCsvWrite() var rustRows = MiniExcelRust.QueryCsv(path, true, readOptions).ToList(); CompareRows(managedRows, rustRows, "csv-write-roundtrip"); Require(rustRows.Count == 3, $"csv-write-roundtrip: expected 3 rows, received {rustRows.Count}."); + + using var stream = new MemoryStream(); + written = MiniExcelRust.SaveAsCsv(stream, initialRows, writeOptions, leaveOpen: true); + Require(written == initialRows.Count && stream.CanWrite, "csv-write-stream: initial write failed."); + written = MiniExcelRust.AppendCsv(stream, appendedRows, writeOptions, leaveOpen: true); + Require(written == appendedRows.Count && stream.CanWrite, "csv-append-stream: append failed."); + stream.Position = 0; + var streamRows = MiniExcelRust.QueryCsv(stream, true, readOptions, leaveOpen: true).ToList(); + CompareRows(rustRows, streamRows, "csv-write-stream-roundtrip"); } finally { @@ -406,6 +429,8 @@ static void VerifyTemplateFill() var templatePath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-template-{Guid.NewGuid():N}.xlsx"); var managedPath = Path.Combine(Path.GetTempPath(), $"miniexcel-managed-template-{Guid.NewGuid():N}.xlsx"); var rustPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-template-output-{Guid.NewGuid():N}.xlsx"); + var bytesPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-template-bytes-{Guid.NewGuid():N}.xlsx"); + var streamTemplatePath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-template-stream-{Guid.NewGuid():N}.xlsx"); var strictPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-template-strict-{Guid.NewGuid():N}.xlsx"); try { @@ -426,10 +451,48 @@ static void VerifyTemplateFill() ManagedMiniExcel.Templaters.GetOpenXmlTemplater().FillTemplate(managedPath, templatePath, value); MiniExcelRust.FillTemplate(rustPath, templatePath, value); - CompareRows( - QueryManaged(managedPath, false).ToList(), - MiniExcelRust.Query(rustPath).ToList(), - "template-fill"); + var expectedRows = QueryManaged(managedPath, false).ToList(); + CompareRows(expectedRows, MiniExcelRust.Query(rustPath).ToList(), "template-fill"); + + var templateBytes = File.ReadAllBytes(templatePath); + MiniExcelRust.FillTemplate(bytesPath, templateBytes, value); + CompareRows(expectedRows, MiniExcelRust.Query(bytesPath).ToList(), "template-fill-bytes"); + + using (var templateStream = new MemoryStream(templateBytes)) + { + MiniExcelRust.FillTemplate(streamTemplatePath, templateStream, value, leaveTemplateOpen: true); + Require(templateStream.CanRead, "template-stream: leaveTemplateOpen should preserve the stream."); + CompareRows(expectedRows, MiniExcelRust.Query(streamTemplatePath).ToList(), "template-fill-template-stream"); + } + + using (var outputStream = new MemoryStream()) + { + MiniExcelRust.FillTemplate(outputStream, templatePath, value, leaveOpen: true); + Require(outputStream.CanWrite, "template-output-stream: leaveOpen should preserve the stream."); + outputStream.Position = 0; + CompareRows(expectedRows, MiniExcelRust.Query(outputStream).ToList(), "template-fill-output-stream"); + } + + using (var outputStream = new MemoryStream()) + using (var templateStream = new MemoryStream(templateBytes)) + { + MiniExcelRust.FillTemplate( + outputStream, + templateStream, + value, + leaveOpen: true, + leaveTemplateOpen: true); + Require(outputStream.CanWrite && templateStream.CanRead, "template-streams: leave-open contract failed."); + outputStream.Position = 0; + CompareRows(expectedRows, MiniExcelRust.Query(outputStream).ToList(), "template-fill-streams"); + } + + using (var outputStream = new MemoryStream()) + { + MiniExcelRust.FillTemplate(outputStream, templateBytes, value, leaveOpen: true); + outputStream.Position = 0; + CompareRows(expectedRows, MiniExcelRust.Query(outputStream).ToList(), "template-fill-byte-stream"); + } var rejectedOverwrite = false; try @@ -455,7 +518,7 @@ static void VerifyTemplateFill() } finally { - foreach (var path in new[] { templatePath, managedPath, rustPath, strictPath }) + foreach (var path in new[] { templatePath, managedPath, rustPath, bytesPath, streamTemplatePath, strictPath }) { if (File.Exists(path)) File.Delete(path); From 726c826642f63d80f7f2979a6050182eb7d99e7c Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sun, 6 Sep 2026 12:59:28 +0800 Subject: [PATCH 08/17] Add Rust-backed multi-sheet XLSX exports --- docs/parity-matrix.md | 5 +- native/miniexcel-ffi/src/lib.rs | 78 +++++++++++++- src/MiniExcelRust/MiniExcelRust.cs | 110 +++++++++++++++++++- tests/MiniExcelRust.PackageTests/Program.cs | 56 ++++++++++ 4 files changed, 243 insertions(+), 6 deletions(-) diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md index 20614ef..9c7a683 100644 --- a/docs/parity-matrix.md +++ b/docs/parity-matrix.md @@ -37,8 +37,9 @@ limits; **Missing** has no production implementation yet. | CSV metadata | Column names | Verified | Path, stream, sync and task-based async | | CSV adapters | DataTable/Reader | Verified | Materialized managed adapters | | CSV write | Dynamic save/append, path/stream | Partial | Delimiter, BOM, header, overwrite and append verified; typed and async remain | -| XLSX write | Dynamic single-sheet `SaveAs`, path/stream | Partial | Basic scalars and overwrite behavior verified; temporal values, schema, styles and multi-sheet remain | -| XLSX write | Typed/async/multi-sheet export | Missing | Rust core exists; input schema/callback ABI required | +| XLSX write | Dynamic single-sheet `SaveAs`, path/stream | Partial | Basic scalars and overwrite behavior verified; temporal values, schema and styles remain | +| XLSX write | Dynamic multi-sheet, path/stream | Verified | Ordered sheets, per-sheet row counts, content and overwrite verified | +| XLSX write | Typed/async export | Missing | Rust core exists; typed input and async producer ABI required | | 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 | diff --git a/native/miniexcel-ffi/src/lib.rs b/native/miniexcel-ffi/src/lib.rs index b82e24d..83721bb 100644 --- a/native/miniexcel-ffi/src/lib.rs +++ b/native/miniexcel-ffi/src/lib.rs @@ -741,6 +741,66 @@ pub unsafe extern "C" fn miniexcel_save_as( }) } +/// 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 a CSV file from encoded dynamic rows. /// /// # Safety @@ -1372,6 +1432,23 @@ unsafe fn write_csv( 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 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 { @@ -1394,7 +1471,6 @@ fn decode_rows(bytes: &[u8]) -> Result, i32> { } rows.push(row); } - reader.ensure_complete()?; Ok(rows) } diff --git a/src/MiniExcelRust/MiniExcelRust.cs b/src/MiniExcelRust/MiniExcelRust.cs index ecb6349..8888100 100644 --- a/src/MiniExcelRust/MiniExcelRust.cs +++ b/src/MiniExcelRust/MiniExcelRust.cs @@ -747,6 +747,74 @@ public static int SaveAs( } } + public static int[] SaveAs( + 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[] SaveAs( + 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 = SaveAs(temporaryPath, sheets, printHeader); + CopyFileToStream(temporaryPath, stream); + return rowCounts; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + /// /// Creates a single-sheet XLSX workbook and copies it to a writable stream. /// @@ -1546,9 +1614,36 @@ private static async IAsyncEnumerable ToAsyncEnumerable( private static byte[] EncodeRows(IEnumerable> rows) { - var materializedRows = rows.ToList(); 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 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) { @@ -1559,8 +1654,6 @@ private static byte[] EncodeRows(IEnumerable> rows) WriteFrameValue(writer, cell.Value); } } - writer.Flush(); - return stream.ToArray(); } private static int WriteCsv( @@ -2030,6 +2123,17 @@ internal static extern int SaveAs( 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_csv", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] internal static extern int SaveCsv( IntPtr path, diff --git a/tests/MiniExcelRust.PackageTests/Program.cs b/tests/MiniExcelRust.PackageTests/Program.cs index 414fcf6..5caa709 100644 --- a/tests/MiniExcelRust.PackageTests/Program.cs +++ b/tests/MiniExcelRust.PackageTests/Program.cs @@ -180,6 +180,7 @@ static int RunSuite(int lifecycleIterations, int maxPrivateGrowthMb) VerifyParity(workbookPath); VerifyCsvParity(csvPath); VerifySaveAs(); + VerifyMultiSheetSaveAs(); VerifyCsvWrite(); VerifyTypedConversions(); VerifyInsertAndCopy(); @@ -253,6 +254,61 @@ static void VerifySaveAs() } } +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.SaveAs(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.SaveAs(path, sheets); + } + catch (InvalidOperationException) + { + rejectedOverwrite = true; + } + Require(rejectedOverwrite, "multi-sheet-save: overwrite=false should reject an existing file."); + counts = MiniExcelRust.SaveAs(path, sheets, overwriteFile: true); + Require(counts.SequenceEqual(new[] { 2, 1 }), "multi-sheet-save: overwrite counts differ."); + + using var stream = new MemoryStream(); + counts = MiniExcelRust.SaveAs(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 VerifyCsvWrite() { var path = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-write-{Guid.NewGuid():N}.csv"); From 474bcc0279d0b1c196ce1e52da27f6bc6c21bfb3 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sun, 6 Sep 2026 14:19:55 +0800 Subject: [PATCH 09/17] Add temporal value and Excel epoch parity to XLSX exports --- Cargo.lock | 1 + docs/parity-matrix.md | 2 +- native/miniexcel-ffi/Cargo.toml | 1 + native/miniexcel-ffi/src/lib.rs | 34 +++++++++++++++++++-- src/MiniExcelRust/MiniExcelRust.cs | 18 +++++++++++ tests/MiniExcelRust.PackageTests/Program.cs | 25 ++++++++++++--- 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e172310..e347864 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -427,6 +427,7 @@ dependencies = [ name = "miniexcel-ffi" version = "0.1.0" dependencies = [ + "chrono", "miniexcel", "serde_json", ] diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md index 9c7a683..1a2cd10 100644 --- a/docs/parity-matrix.md +++ b/docs/parity-matrix.md @@ -37,7 +37,7 @@ limits; **Missing** has no production implementation yet. | CSV metadata | Column names | Verified | Path, stream, sync and task-based async | | CSV adapters | DataTable/Reader | Verified | Materialized managed adapters | | CSV write | Dynamic save/append, path/stream | Partial | Delimiter, BOM, header, overwrite and append verified; typed and async remain | -| XLSX write | Dynamic single-sheet `SaveAs`, path/stream | Partial | Basic scalars and overwrite behavior verified; temporal values, schema and styles remain | +| XLSX write | Dynamic single-sheet `SaveAs`, path/stream | Partial | Scalars, temporal values, overwrite and Excel epoch parity verified; explicit schema and styles remain | | XLSX write | Dynamic multi-sheet, path/stream | Verified | Ordered sheets, per-sheet row counts, content and overwrite verified | | XLSX write | Typed/async export | Missing | Rust core exists; typed input and async producer ABI required | | Workbook edits | Rename/reorder/visibility | Verified | Atomic path operations checked through C# and Rust metadata readers | diff --git a/native/miniexcel-ffi/Cargo.toml b/native/miniexcel-ffi/Cargo.toml index aa2836d..a86f4ad 100644 --- a/native/miniexcel-ffi/Cargo.toml +++ b/native/miniexcel-ffi/Cargo.toml @@ -13,5 +13,6 @@ publish = false crate-type = ["cdylib"] [dependencies] +chrono = "0.4.45" miniexcel = "=0.4.0" serde_json = "1.0" diff --git a/native/miniexcel-ffi/src/lib.rs b/native/miniexcel-ffi/src/lib.rs index 83721bb..6f654de 100644 --- a/native/miniexcel-ffi/src/lib.rs +++ b/native/miniexcel-ffi/src/lib.rs @@ -4,6 +4,7 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::ptr; use std::str::FromStr; +use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime}; use miniexcel::{ CellReference, CellValue, CommentPerson, CommentTimestamp, CsvConfiguration, CsvEncoding, CsvReadOptions, CsvWriteOptions, DynamicRow, ExistingSheetPolicy, HeaderMode, InsertOptions, @@ -1281,11 +1282,19 @@ fn write_row(frame: &mut Vec, row: &DynamicRow) -> Result<(), i32> { } 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(8); - frame.extend_from_slice(&value.num_milliseconds().to_le_bytes()); + 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); @@ -1462,6 +1471,20 @@ fn read_rows(reader: &mut FrameInput<'_>) -> Result, i32> { 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); @@ -1474,6 +1497,13 @@ fn read_rows(reader: &mut FrameInput<'_>) -> Result, i32> { 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, diff --git a/src/MiniExcelRust/MiniExcelRust.cs b/src/MiniExcelRust/MiniExcelRust.cs index 8888100..3aaf698 100644 --- a/src/MiniExcelRust/MiniExcelRust.cs +++ b/src/MiniExcelRust/MiniExcelRust.cs @@ -1741,6 +1741,24 @@ private static void WriteFrameValue(BinaryWriter writer, object? value) 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."); } diff --git a/tests/MiniExcelRust.PackageTests/Program.cs b/tests/MiniExcelRust.PackageTests/Program.cs index 5caa709..05beed9 100644 --- a/tests/MiniExcelRust.PackageTests/Program.cs +++ b/tests/MiniExcelRust.PackageTests/Program.cs @@ -204,8 +204,26 @@ 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 }, - new Dictionary { ["Name"] = "beta", ["Value"] = null, ["Enabled"] = false } + 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 { @@ -214,7 +232,6 @@ static void VerifySaveAs() var managedRows = QueryManaged(path, true, "Exported").ToList(); var rustRows = MiniExcelRust.Query(path, true, "Exported").ToList(); CompareRows(managedRows, rustRows, "save-as-roundtrip"); - CompareRows(rows, rustRows, "save-as-input"); var rejectedExistingFile = false; try @@ -240,7 +257,7 @@ static void VerifySaveAs() var managedStreamRows = importer.Query(stream, true, "Streamed", leaveOpen: true) .Cast>() .ToList(); - CompareRows(rows, managedStreamRows, "save-as-stream"); + CompareRows(managedRows, managedStreamRows, "save-as-stream"); } var closingStream = new MemoryStream(); From 3f3b96797feb9315ce25d50a55010cfe01aec56b Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Sun, 6 Sep 2026 14:39:55 +0800 Subject: [PATCH 10/17] Add typed XLSX and CSV exports with async XLSX support --- docs/parity-matrix.md | 4 +- native/miniexcel-ffi/src/lib.rs | 182 ++++++++++++++++++ src/MiniExcelRust/MiniExcelRust.cs | 155 ++++++++++++++- src/MiniExcelRust/MiniExcelRustMapper.cs | 40 +++- .../MiniExcelRustWriteOptions.cs | 26 +++ tests/MiniExcelRust.PackageTests/Program.cs | 156 ++++++++++++++- 6 files changed, 553 insertions(+), 10 deletions(-) create mode 100644 src/MiniExcelRust/MiniExcelRustWriteOptions.cs diff --git a/docs/parity-matrix.md b/docs/parity-matrix.md index 1a2cd10..da4c83a 100644 --- a/docs/parity-matrix.md +++ b/docs/parity-matrix.md @@ -37,9 +37,9 @@ limits; **Missing** has no production implementation yet. | CSV metadata | Column names | Verified | Path, stream, sync and task-based async | | CSV adapters | DataTable/Reader | Verified | Materialized managed adapters | | CSV write | Dynamic save/append, path/stream | Partial | Delimiter, BOM, header, overwrite and append verified; typed and async remain | -| XLSX write | Dynamic single-sheet `SaveAs`, path/stream | Partial | Scalars, temporal values, overwrite and Excel epoch parity verified; explicit schema and styles remain | +| XLSX write | Dynamic single-sheet `SaveAs`, path/stream | Partial | Scalars, temporal values, overwrite, explicit schema, filter/RTL/freeze/width/hidden/format verified; advanced header/alignment styles remain | | XLSX write | Dynamic multi-sheet, path/stream | Verified | Ordered sheets, per-sheet row counts, content and overwrite verified | -| XLSX write | Typed/async export | Missing | Rust core exists; typed input and async producer ABI required | +| XLSX write | Typed/async export | Partial | POCO attributes/scalars and pre-cancellation verified; producer currently materializes before native write | | 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 | diff --git a/native/miniexcel-ffi/src/lib.rs b/native/miniexcel-ffi/src/lib.rs index 6f654de..f105d99 100644 --- a/native/miniexcel-ffi/src/lib.rs +++ b/native/miniexcel-ffi/src/lib.rs @@ -802,6 +802,49 @@ pub unsafe extern "C" fn miniexcel_save_as_sheets( }) } +/// 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 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)?; + if let Some(schema) = configured_schema(&payload)? { + MiniExcel::save_as_with_schema(path, &schema, &rows, &options) + } else { + MiniExcel::save_as_with_options(path, &rows, &options) + } + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + write_row_count(rows.len(), out_row_count) + }) +} + /// Creates a CSV file from encoded dynamic rows. /// /// # Safety @@ -1446,6 +1489,145 @@ fn decode_rows(bytes: &[u8]) -> Result, i32> { 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_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"))?, + ); + 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 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 decode_sheets(bytes: &[u8]) -> Result)>, i32> { let mut reader = FrameInput::new(bytes); let sheet_count = reader.read_length()?; diff --git a/src/MiniExcelRust/MiniExcelRust.cs b/src/MiniExcelRust/MiniExcelRust.cs index 3aaf698..2a7835e 100644 --- a/src/MiniExcelRust/MiniExcelRust.cs +++ b/src/MiniExcelRust/MiniExcelRust.cs @@ -747,7 +747,60 @@ public static int SaveAs( } } - public static int[] SaveAs( + public static int SaveAs( + string path, + IEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool overwriteFile = false) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (rows is IEnumerable> dynamicRows) + return SaveAs(path, dynamicRows, printHeader, sheetName, overwriteFile); + return SaveAs( + path, + MiniExcelRustMapper.ToRows(rows), + printHeader, + sheetName, + overwriteFile); + } + + public static int SaveAs( + Stream stream, + IEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool leaveOpen = false) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (rows is IEnumerable> dynamicRows) + return SaveAs(stream, dynamicRows, printHeader, sheetName, leaveOpen); + return SaveAs(stream, MiniExcelRustMapper.ToRows(rows), printHeader, sheetName, leaveOpen); + } + + public static async Task SaveAsAsync( + string path, + IAsyncEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool overwriteFile = false, + CancellationToken cancellationToken = default) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + cancellationToken.ThrowIfCancellationRequested(); + var materialized = new List(); + await foreach (var row in rows.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + materialized.Add(row); + } + return SaveAs(path, materialized, printHeader, sheetName, overwriteFile); + } + + public static int[] SaveAsSheets( string path, IEnumerable>>> sheets, bool printHeader = true, @@ -793,7 +846,7 @@ public static int[] SaveAs( } } - public static int[] SaveAs( + public static int[] SaveAsSheets( Stream stream, IEnumerable>>> sheets, bool printHeader = true, @@ -803,7 +856,7 @@ public static int[] SaveAs( var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); try { - var rowCounts = SaveAs(temporaryPath, sheets, printHeader); + var rowCounts = SaveAsSheets(temporaryPath, sheets, printHeader); CopyFileToStream(temporaryPath, stream); return rowCounts; } @@ -815,6 +868,71 @@ public static int[] SaveAs( } } + 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(); + + 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, + options.MinWidth, + options.MaxWidth, + options.FreezeRowCount, + options.FreezeColumnCount, + options.DateFormat, + options.TimeFormat, + options.DateTimeFormat, + options.DurationFormat, + options.ColumnFormats, + options.ColumnWidths, + options.HiddenColumns + }, 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(); + } + } + /// /// Creates a single-sheet XLSX workbook and copies it to a writable stream. /// @@ -857,6 +975,18 @@ public static int SaveAsCsv( 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); + } + /// /// Appends dynamic rows to a CSV file without repeating its header. /// @@ -868,6 +998,16 @@ public static int AppendCsv( 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 SaveAsCsv( Stream stream, IEnumerable> rows, @@ -2152,6 +2292,15 @@ internal static extern int SaveAsSheets( 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, diff --git a/src/MiniExcelRust/MiniExcelRustMapper.cs b/src/MiniExcelRust/MiniExcelRustMapper.cs index 1804892..8ce4c94 100644 --- a/src/MiniExcelRust/MiniExcelRustMapper.cs +++ b/src/MiniExcelRust/MiniExcelRustMapper.cs @@ -6,6 +6,22 @@ namespace MiniExcelLibs; internal static class MiniExcelRustMapper { + public static IEnumerable> ToRows(IEnumerable values) + { + var mappings = CreateMappings(typeof(T)) + .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.GetValue(value))); + yield return row; + } + } + public static IEnumerable Map(IEnumerable> rows) where T : class, new() { @@ -47,7 +63,7 @@ private static IReadOnlyList CreateMappings(Type type) { const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public; var members = type.GetProperties(flags) - .Where(property => property.SetMethod is not null) + .Where(property => property.SetMethod is not null && property.GetIndexParameters().Length == 0) .Cast() .Concat(type.GetFields(flags).Where(HasMiniExcelAttribute)); return members @@ -152,6 +168,21 @@ private static bool Assign(object? source, out object? value) return Convert.ChangeType(value, effectiveType, CultureInfo.InvariantCulture); } + 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); @@ -232,5 +263,12 @@ public void SetValue(object target, object? value) else ((FieldInfo)member).SetValue(target, value); } + + public object? GetValue(object target) + { + return member is PropertyInfo property + ? property.GetValue(target) + : ((FieldInfo)member).GetValue(target); + } } } \ No newline at end of file diff --git a/src/MiniExcelRust/MiniExcelRustWriteOptions.cs b/src/MiniExcelRust/MiniExcelRustWriteOptions.cs new file mode 100644 index 0000000..ef8fcff --- /dev/null +++ b/src/MiniExcelRust/MiniExcelRustWriteOptions.cs @@ -0,0 +1,26 @@ +namespace MiniExcelLibs; + +/// +/// 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 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(); +} \ No newline at end of file diff --git a/tests/MiniExcelRust.PackageTests/Program.cs b/tests/MiniExcelRust.PackageTests/Program.cs index 05beed9..74e15b1 100644 --- a/tests/MiniExcelRust.PackageTests/Program.cs +++ b/tests/MiniExcelRust.PackageTests/Program.cs @@ -181,8 +181,10 @@ static int RunSuite(int lifecycleIterations, int maxPrivateGrowthMb) VerifyCsvParity(csvPath); VerifySaveAs(); VerifyMultiSheetSaveAs(); + VerifyConfiguredWrite(); VerifyCsvWrite(); VerifyTypedConversions(); + VerifyTypedExports(); VerifyInsertAndCopy(); VerifyTemplateFill(); VerifyWorkbookMutations(workbookPath); @@ -290,7 +292,7 @@ static void VerifyMultiSheetSaveAs() }; try { - var counts = MiniExcelRust.SaveAs(path, sheets); + 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), @@ -301,18 +303,18 @@ static void VerifyMultiSheetSaveAs() var rejectedOverwrite = false; try { - MiniExcelRust.SaveAs(path, sheets); + MiniExcelRust.SaveAsSheets(path, sheets); } catch (InvalidOperationException) { rejectedOverwrite = true; } Require(rejectedOverwrite, "multi-sheet-save: overwrite=false should reject an existing file."); - counts = MiniExcelRust.SaveAs(path, sheets, overwriteFile: true); + 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.SaveAs(stream, sheets, leaveOpen: true); + counts = MiniExcelRust.SaveAsSheets(stream, sheets, leaveOpen: true); Require(counts.SequenceEqual(new[] { 2, 1 }) && stream.CanWrite, "multi-sheet-stream: write failed."); stream.Position = 0; Require( @@ -326,6 +328,59 @@ static void VerifyMultiSheetSaveAs() } } +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, + 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("