diff --git a/.github/workflows/dotnet-nuget.yml b/.github/workflows/dotnet-nuget.yml new file mode 100644 index 0000000..4636f27 --- /dev/null +++ b/.github/workflows/dotnet-nuget.yml @@ -0,0 +1,165 @@ +name: .NET NuGet + +on: + pull_request: + paths: + - '.github/workflows/dotnet-nuget.yml' + - 'Cargo.lock' + - 'Cargo.toml' + - 'miniexcel/**' + - 'miniexcel-ffi/**' + - 'dotnet/**' + - 'scripts/dotnet/**' + push: + branches: [main] + paths: + - '.github/workflows/dotnet-nuget.yml' + - 'Cargo.lock' + - 'Cargo.toml' + - 'miniexcel/**' + - 'miniexcel-ffi/**' + - 'dotnet/**' + - 'scripts/dotnet/**' + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + PACKAGE_VERSION: 0.1.0-ci.${{ github.run_number }} + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.85.0 + with: + components: clippy, rustfmt + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - uses: Swatinem/rust-cache@v2 + with: + key: dotnet-ffi + - name: Check FFI formatting + run: cargo fmt --all -- --check + - name: Check FFI lints + run: cargo clippy -p miniexcel-ffi --all-targets --locked -- -D warnings + - name: Test FFI + run: cargo test -p miniexcel-ffi --all-targets --locked + - name: Build managed package + run: dotnet build dotnet/src/MiniExcel.Rust/MiniExcel.Rust.csproj -c Release + + native-package-test: + name: Test ${{ matrix.rid }} package + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + rid: win-x64 + - runner: windows-11-arm + rid: win-arm64 + - runner: ubuntu-latest + rid: linux-x64 + - runner: ubuntu-24.04-arm + rid: linux-arm64 + - runner: macos-15-intel + rid: osx-x64 + - runner: macos-latest + rid: osx-arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.85.0 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - uses: Swatinem/rust-cache@v2 + with: + key: dotnet-${{ matrix.rid }} + - name: Build, pack, and consume package + shell: pwsh + run: ./scripts/dotnet/Test-Package.ps1 -Rid '${{ matrix.rid }}' -Version $env:PACKAGE_VERSION + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.rid }} + path: target/nuget/native/${{ matrix.rid }}/* + if-no-files-found: error + + musl-package-test: + name: Test ${{ matrix.rid }} package + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + rid: linux-musl-x64 + - runner: ubuntu-24.04-arm + rid: linux-musl-arm64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.85.0 + with: + targets: ${{ matrix.rid == 'linux-musl-x64' && 'x86_64-unknown-linux-musl' || 'aarch64-unknown-linux-musl' }} + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - uses: mlugg/setup-zig@v2 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-zigbuild + - uses: Swatinem/rust-cache@v2 + with: + key: dotnet-${{ matrix.rid }} + - name: Build native asset + shell: pwsh + run: ./scripts/dotnet/Build-Native.ps1 -Rid '${{ matrix.rid }}' -UseZig -Toolchain 1.85.0 + - name: Pack one-RID package + run: >- + dotnet pack dotnet/src/MiniExcel.Rust/MiniExcel.Rust.csproj + -c Release + -o target/nuget/packages + -p:PackageVersion=${{ env.PACKAGE_VERSION }} + -p:MiniExcelRustRequireAllNativeAssets=false + - name: Consume package in Alpine + run: >- + docker run --rm + -v "$GITHUB_WORKSPACE:/work" + -w /work + mcr.microsoft.com/dotnet/sdk:8.0-alpine + sh -lc 'dotnet restore dotnet/tests/MiniExcel.Rust.PackageTests/MiniExcel.Rust.PackageTests.csproj --source target/nuget/packages --source https://api.nuget.org/v3/index.json -p:MiniExcelRustPackageVersion=${{ env.PACKAGE_VERSION }} && dotnet run --project dotnet/tests/MiniExcel.Rust.PackageTests/MiniExcel.Rust.PackageTests.csproj -c Release --no-restore -p:MiniExcelRustPackageVersion=${{ env.PACKAGE_VERSION }}' + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.rid }} + path: target/nuget/native/${{ matrix.rid }}/* + if-no-files-found: error + + assemble-package: + needs: [validate, native-package-test, musl-package-test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - uses: actions/download-artifact@v4 + with: + pattern: '*' + path: target/nuget/native + - name: Pack complete NuGet + run: >- + dotnet pack dotnet/src/MiniExcel.Rust/MiniExcel.Rust.csproj + -c Release + -o target/nuget/packages + -p:PackageVersion=${{ env.PACKAGE_VERSION }} + - name: Verify package contents + shell: pwsh + run: ./scripts/dotnet/Verify-Package.ps1 -PackagePath "target/nuget/packages/MiniExcel.Rust.$env:PACKAGE_VERSION.nupkg" + - uses: actions/upload-artifact@v4 + with: + name: MiniExcel.Rust + path: target/nuget/packages/MiniExcel.Rust.${{ env.PACKAGE_VERSION }}.*nupkg + if-no-files-found: error diff --git a/.github/workflows/nuget-release.yml b/.github/workflows/nuget-release.yml new file mode 100644 index 0000000..eed813d --- /dev/null +++ b/.github/workflows/nuget-release.yml @@ -0,0 +1,235 @@ +name: NuGet Release + +on: + push: + tags: + - 'nuget-v*.*.*' + workflow_dispatch: + inputs: + version: + description: NuGet version, for example 0.1.0-preview.1 + required: true + default: 0.1.0-preview.1 + type: string + publish: + description: Publish to NuGet.org after all package tests pass + required: true + default: false + type: boolean + +permissions: + contents: read + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.value }} + steps: + - id: version + name: Validate version + shell: bash + env: + REQUESTED_VERSION: ${{ github.event_name == 'push' && github.ref_name || inputs.version }} + run: | + version="${REQUESTED_VERSION#nuget-v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]; then + echo "::error::Version '$version' is not a valid supported NuGet version." + exit 1 + fi + echo "value=$version" >> "$GITHUB_OUTPUT" + + build-native: + needs: prepare + name: Build ${{ matrix.rid }} + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + rid: win-x64 + target: x86_64-pc-windows-msvc + zig: false + - runner: windows-11-arm + rid: win-arm64 + target: aarch64-pc-windows-msvc + zig: false + - runner: ubuntu-latest + rid: linux-x64 + target: x86_64-unknown-linux-gnu + zig: false + - runner: ubuntu-24.04-arm + rid: linux-arm64 + target: aarch64-unknown-linux-gnu + zig: false + - runner: ubuntu-latest + rid: linux-musl-x64 + target: x86_64-unknown-linux-musl + zig: true + - runner: ubuntu-24.04-arm + rid: linux-musl-arm64 + target: aarch64-unknown-linux-musl + zig: true + - runner: macos-15-intel + rid: osx-x64 + target: x86_64-apple-darwin + zig: false + - runner: macos-latest + rid: osx-arm64 + target: aarch64-apple-darwin + zig: false + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.85.0 + with: + targets: ${{ matrix.target }} + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - uses: mlugg/setup-zig@v2 + if: matrix.zig + - uses: taiki-e/install-action@v2 + if: matrix.zig + with: + tool: cargo-zigbuild + - uses: Swatinem/rust-cache@v2 + with: + key: nuget-release-${{ matrix.rid }} + - name: Build native asset + shell: pwsh + run: | + $arguments = @{ + Rid = '${{ matrix.rid }}' + Toolchain = '1.85.0' + } + if ('${{ matrix.zig }}' -eq 'true') { + $arguments.UseZig = $true + } + ./scripts/dotnet/Build-Native.ps1 @arguments + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.rid }} + path: target/nuget/native/${{ matrix.rid }}/* + if-no-files-found: error + + pack: + needs: [prepare, build-native] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - uses: actions/download-artifact@v4 + with: + pattern: '*' + path: target/nuget/native + - name: Pack complete NuGet + run: >- + dotnet pack dotnet/src/MiniExcel.Rust/MiniExcel.Rust.csproj + -c Release + -o target/nuget/packages + -p:PackageVersion=${{ needs.prepare.outputs.version }} + - name: Verify package contents + shell: pwsh + run: ./scripts/dotnet/Verify-Package.ps1 -PackagePath 'target/nuget/packages/MiniExcel.Rust.${{ needs.prepare.outputs.version }}.nupkg' + - uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: target/nuget/packages/MiniExcel.Rust.${{ needs.prepare.outputs.version }}.*nupkg + if-no-files-found: error + + test-package: + needs: [prepare, pack] + name: Consume ${{ matrix.rid }} package + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + rid: win-x64 + musl: false + - runner: windows-11-arm + rid: win-arm64 + musl: false + - runner: ubuntu-latest + rid: linux-x64 + musl: false + - runner: ubuntu-24.04-arm + rid: linux-arm64 + musl: false + - runner: ubuntu-latest + rid: linux-musl-x64 + musl: true + - runner: ubuntu-24.04-arm + rid: linux-musl-arm64 + musl: true + - runner: macos-15-intel + rid: osx-x64 + musl: false + - runner: macos-latest + rid: osx-arm64 + musl: false + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - uses: actions/download-artifact@v4 + with: + name: nuget-package + path: target/nuget/packages + - name: Consume package + if: ${{ !matrix.musl }} + shell: pwsh + run: | + dotnet new nugetconfig --output target/nuget/restore --force + dotnet nuget add source "$env:GITHUB_WORKSPACE/target/nuget/packages" --name MiniExcelRustLocal --configfile target/nuget/restore/nuget.config + dotnet restore dotnet/tests/MiniExcel.Rust.PackageTests/MiniExcel.Rust.PackageTests.csproj --force --no-cache --configfile target/nuget/restore/nuget.config -p:MiniExcelRustPackageVersion='${{ needs.prepare.outputs.version }}' + dotnet run --project dotnet/tests/MiniExcel.Rust.PackageTests/MiniExcel.Rust.PackageTests.csproj -c Release --no-restore -p:MiniExcelRustPackageVersion='${{ needs.prepare.outputs.version }}' + - name: Consume package in Alpine + if: matrix.musl + run: >- + docker run --rm + -v "$GITHUB_WORKSPACE:/work" + -w /work + mcr.microsoft.com/dotnet/sdk:8.0-alpine + sh -lc 'dotnet restore dotnet/tests/MiniExcel.Rust.PackageTests/MiniExcel.Rust.PackageTests.csproj --source target/nuget/packages --source https://api.nuget.org/v3/index.json -p:MiniExcelRustPackageVersion=${{ needs.prepare.outputs.version }} && dotnet run --project dotnet/tests/MiniExcel.Rust.PackageTests/MiniExcel.Rust.PackageTests.csproj -c Release --no-restore -p:MiniExcelRustPackageVersion=${{ needs.prepare.outputs.version }}' + + publish: + needs: [prepare, test-package] + if: github.event_name == 'push' || inputs.publish == true + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: nuget-package + path: target/nuget/packages + - name: NuGet login + id: nuget-login + uses: NuGet/login@v1 + with: + user: ITWeiHan + - name: Publish MiniExcel.Rust + run: >- + dotnet nuget push + target/nuget/packages/MiniExcel.Rust.${{ needs.prepare.outputs.version }}.nupkg + --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" + --source https://api.nuget.org/v3/index.json + --skip-duplicate + - name: Create GitHub release + if: github.event_name == 'push' + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release create '${{ github.ref_name }}' + target/nuget/packages/* + --verify-tag + --generate-notes + --title 'MiniExcel.Rust ${{ needs.prepare.outputs.version }}' diff --git a/.gitignore b/.gitignore index 33e299a..36de338 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ /target/ /benchmarks/dotnet-v1-query/bin/ /benchmarks/dotnet-v1-query/obj/ +/dotnet/**/bin/ +/dotnet/**/obj/ /web-demo/dist/ /web-demo/node_modules/ /web-demo/playwright-report/ diff --git a/Cargo.lock b/Cargo.lock index e676822..5ad4120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -636,7 +636,15 @@ dependencies = [ name = "miniexcel-ffi" version = "0.4.0" dependencies = [ + "atomicwrites", + "chrono", + "futures-executor", + "futures-util", "miniexcel", + "quick-xml", + "serde_json", + "tempfile", + "zip", ] [[package]] diff --git a/README.es.md b/README.es.md index 521f7e3..ecff39d 100644 --- a/README.es.md +++ b/README.es.md @@ -45,6 +45,30 @@ cargo add miniexcel Requiere Rust 1.85.0 o posterior. +### Paquete .NET + +El repositorio también genera el paquete NuGet preliminar `MiniExcel.Rust`. Depende de MiniExcel +v1 (`1.46.0` o una versión 1.x posterior), reutiliza sus tipos de configuración y mapping, y +ejecuta mediante la biblioteca nativa de Rust las llamadas realizadas a `MiniExcelRust`. + +```bash +dotnet add package MiniExcel.Rust --prerelease +``` + +```csharp +using MiniExcelLibs; +using MiniExcelLibs.OpenXml; + +var rows = MiniExcelRust.Query( + "book.xlsx", + useHeaderRow: true, + configuration: new OpenXmlConfiguration { IgnoreEmptyRows = true }); +``` + +`MiniExcel.Query` usa la implementación administrada original y `MiniExcelRust.Query` usa el +backend de Rust. Genera y prueba un paquete local con +`./scripts/dotnet/Test-Package.ps1 -Rid win-x64`. + ## Inicio Rápido ```rust diff --git a/README.fr.md b/README.fr.md index 98a6324..f4e2340 100644 --- a/README.fr.md +++ b/README.fr.md @@ -45,6 +45,30 @@ cargo add miniexcel Nécessite Rust 1.85.0 ou ultérieur. +### Package .NET + +Le dépôt construit également le package NuGet préliminaire `MiniExcel.Rust`. Il dépend de +MiniExcel v1 (`1.46.0` ou une version 1.x ultérieure), réutilise ses types de configuration et de +mapping, et transmet les appels `MiniExcelRust` à la bibliothèque native Rust. + +```bash +dotnet add package MiniExcel.Rust --prerelease +``` + +```csharp +using MiniExcelLibs; +using MiniExcelLibs.OpenXml; + +var rows = MiniExcelRust.Query( + "book.xlsx", + useHeaderRow: true, + configuration: new OpenXmlConfiguration { IgnoreEmptyRows = true }); +``` + +`MiniExcel.Query` utilise l'implémentation managée d'origine ; `MiniExcelRust.Query` utilise le +backend Rust. Construisez et testez un package local avec +`./scripts/dotnet/Test-Package.ps1 -Rid win-x64`. + ## Démarrage Rapide ```rust diff --git a/README.ja.md b/README.ja.md index a80c63b..ada3097 100644 --- a/README.ja.md +++ b/README.ja.md @@ -45,6 +45,29 @@ cargo add miniexcel Rust 1.85.0 以降が必要です。 +### .NET パッケージ + +このリポジトリはプレビュー版 `MiniExcel.Rust` NuGet パッケージもビルドします。MiniExcel +v1(`1.46.0` またはそれ以降の 1.x)に依存し、その設定型と mapping 型を再利用しながら、 +`MiniExcelRust` の呼び出しを Rust ネイティブライブラリで実行します。 + +```bash +dotnet add package MiniExcel.Rust --prerelease +``` + +```csharp +using MiniExcelLibs; +using MiniExcelLibs.OpenXml; + +var rows = MiniExcelRust.Query( + "book.xlsx", + useHeaderRow: true, + configuration: new OpenXmlConfiguration { IgnoreEmptyRows = true }); +``` + +`MiniExcel.Query` は元の managed 実装を、`MiniExcelRust.Query` は Rust backend を使用します。 +ローカルパッケージは `./scripts/dotnet/Test-Package.ps1 -Rid win-x64` でビルドして検証できます。 + ## クイックスタート ```rust diff --git a/README.md b/README.md index ec546a1..a0981fb 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,30 @@ cargo add miniexcel Requires Rust 1.85.0 or later. +### .NET Package + +The repository also builds the prerelease `MiniExcel.Rust` NuGet package. It depends on MiniExcel +v1 (`1.46.0` or a later 1.x release), reuses its configuration and mapping types, and routes calls +made through `MiniExcelRust` to the Rust native library. + +```bash +dotnet add package MiniExcel.Rust --prerelease +``` + +```csharp +using MiniExcelLibs; +using MiniExcelLibs.OpenXml; + +var rows = MiniExcelRust.Query( + "book.xlsx", + useHeaderRow: true, + configuration: new OpenXmlConfiguration { IgnoreEmptyRows = true }); +``` + +Use `MiniExcel.Query` for the original managed implementation and `MiniExcelRust.Query` for the +Rust-backed implementation. Build and consume a local package with +`./scripts/dotnet/Test-Package.ps1 -Rid win-x64`. + ## Quick Start ```rust diff --git a/README.zh-CN.md b/README.zh-CN.md index 14997f9..01cca9f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -45,6 +45,28 @@ cargo add miniexcel 最低支持 Rust 1.85.0。 +### .NET 包 + +本仓库还会构建预览版 `MiniExcel.Rust` NuGet 包。它依赖 MiniExcel v1(`1.46.0` 或更新的 +1.x 版本),复用其配置和映射类型,并将通过 `MiniExcelRust` 发起的调用交给 Rust 原生库执行。 + +```bash +dotnet add package MiniExcel.Rust --prerelease +``` + +```csharp +using MiniExcelLibs; +using MiniExcelLibs.OpenXml; + +var rows = MiniExcelRust.Query( + "book.xlsx", + useHeaderRow: true, + configuration: new OpenXmlConfiguration { IgnoreEmptyRows = true }); +``` + +`MiniExcel.Query` 使用原托管实现,`MiniExcelRust.Query` 使用 Rust 后端。可通过 +`./scripts/dotnet/Test-Package.ps1 -Rid win-x64` 构建并消费本地包。 + ## 快速开始 ```rust diff --git a/README.zh-TW.md b/README.zh-TW.md index b02a447..f8f6f23 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -45,6 +45,28 @@ cargo add miniexcel 最低支援 Rust 1.85.0。 +### .NET 套件 + +本儲存庫也會建置預覽版 `MiniExcel.Rust` NuGet 套件。它依賴 MiniExcel v1(`1.46.0` 或更新的 +1.x 版本),重用其設定與 mapping 型別,並將透過 `MiniExcelRust` 發起的呼叫交給 Rust 原生程式庫執行。 + +```bash +dotnet add package MiniExcel.Rust --prerelease +``` + +```csharp +using MiniExcelLibs; +using MiniExcelLibs.OpenXml; + +var rows = MiniExcelRust.Query( + "book.xlsx", + useHeaderRow: true, + configuration: new OpenXmlConfiguration { IgnoreEmptyRows = true }); +``` + +`MiniExcel.Query` 使用原本的 managed 實作,`MiniExcelRust.Query` 使用 Rust 後端。可透過 +`./scripts/dotnet/Test-Package.ps1 -Rid win-x64` 建置並使用本機套件。 + ## 快速開始 ```rust diff --git a/docs/i18n/README.es.md b/docs/i18n/README.es.md index bc2fe91..05a6e46 100644 --- a/docs/i18n/README.es.md +++ b/docs/i18n/README.es.md @@ -45,6 +45,30 @@ cargo add miniexcel Requiere Rust 1.85.0 o posterior. +### Paquete .NET + +El repositorio también genera el paquete NuGet preliminar `MiniExcel.Rust`. Depende de MiniExcel +v1 (`1.46.0` o una versión 1.x posterior), reutiliza sus tipos de configuración y mapping, y +ejecuta mediante la biblioteca nativa de Rust las llamadas realizadas a `MiniExcelRust`. + +```bash +dotnet add package MiniExcel.Rust --prerelease +``` + +```csharp +using MiniExcelLibs; +using MiniExcelLibs.OpenXml; + +var rows = MiniExcelRust.Query( + "book.xlsx", + useHeaderRow: true, + configuration: new OpenXmlConfiguration { IgnoreEmptyRows = true }); +``` + +`MiniExcel.Query` usa la implementación administrada original y `MiniExcelRust.Query` usa el +backend de Rust. Genera y prueba un paquete local con +`./scripts/dotnet/Test-Package.ps1 -Rid win-x64`. + ## Inicio Rápido ```rust diff --git a/docs/i18n/README.fr.md b/docs/i18n/README.fr.md index 6c5c267..92aa7d5 100644 --- a/docs/i18n/README.fr.md +++ b/docs/i18n/README.fr.md @@ -45,6 +45,30 @@ cargo add miniexcel Nécessite Rust 1.85.0 ou ultérieur. +### Package .NET + +Le dépôt construit également le package NuGet préliminaire `MiniExcel.Rust`. Il dépend de +MiniExcel v1 (`1.46.0` ou une version 1.x ultérieure), réutilise ses types de configuration et de +mapping, et transmet les appels `MiniExcelRust` à la bibliothèque native Rust. + +```bash +dotnet add package MiniExcel.Rust --prerelease +``` + +```csharp +using MiniExcelLibs; +using MiniExcelLibs.OpenXml; + +var rows = MiniExcelRust.Query( + "book.xlsx", + useHeaderRow: true, + configuration: new OpenXmlConfiguration { IgnoreEmptyRows = true }); +``` + +`MiniExcel.Query` utilise l'implémentation managée d'origine ; `MiniExcelRust.Query` utilise le +backend Rust. Construisez et testez un package local avec +`./scripts/dotnet/Test-Package.ps1 -Rid win-x64`. + ## Démarrage Rapide ```rust diff --git a/docs/i18n/README.ja.md b/docs/i18n/README.ja.md index 42d24b4..04b1957 100644 --- a/docs/i18n/README.ja.md +++ b/docs/i18n/README.ja.md @@ -45,6 +45,29 @@ cargo add miniexcel Rust 1.85.0 以降が必要です。 +### .NET パッケージ + +このリポジトリはプレビュー版 `MiniExcel.Rust` NuGet パッケージもビルドします。MiniExcel +v1(`1.46.0` またはそれ以降の 1.x)に依存し、その設定型と mapping 型を再利用しながら、 +`MiniExcelRust` の呼び出しを Rust ネイティブライブラリで実行します。 + +```bash +dotnet add package MiniExcel.Rust --prerelease +``` + +```csharp +using MiniExcelLibs; +using MiniExcelLibs.OpenXml; + +var rows = MiniExcelRust.Query( + "book.xlsx", + useHeaderRow: true, + configuration: new OpenXmlConfiguration { IgnoreEmptyRows = true }); +``` + +`MiniExcel.Query` は元の managed 実装を、`MiniExcelRust.Query` は Rust backend を使用します。 +ローカルパッケージは `./scripts/dotnet/Test-Package.ps1 -Rid win-x64` でビルドして検証できます。 + ## クイックスタート ```rust diff --git a/docs/i18n/README.zh-CN.md b/docs/i18n/README.zh-CN.md index 0cb7ed9..628b81d 100644 --- a/docs/i18n/README.zh-CN.md +++ b/docs/i18n/README.zh-CN.md @@ -45,6 +45,28 @@ cargo add miniexcel 最低支持 Rust 1.85.0。 +### .NET 包 + +本仓库还会构建预览版 `MiniExcel.Rust` NuGet 包。它依赖 MiniExcel v1(`1.46.0` 或更新的 +1.x 版本),复用其配置和映射类型,并将通过 `MiniExcelRust` 发起的调用交给 Rust 原生库执行。 + +```bash +dotnet add package MiniExcel.Rust --prerelease +``` + +```csharp +using MiniExcelLibs; +using MiniExcelLibs.OpenXml; + +var rows = MiniExcelRust.Query( + "book.xlsx", + useHeaderRow: true, + configuration: new OpenXmlConfiguration { IgnoreEmptyRows = true }); +``` + +`MiniExcel.Query` 使用原托管实现,`MiniExcelRust.Query` 使用 Rust 后端。可通过 +`./scripts/dotnet/Test-Package.ps1 -Rid win-x64` 构建并消费本地包。 + ## 快速开始 ```rust diff --git a/docs/i18n/README.zh-TW.md b/docs/i18n/README.zh-TW.md index 3bf1c47..56ba9e0 100644 --- a/docs/i18n/README.zh-TW.md +++ b/docs/i18n/README.zh-TW.md @@ -45,6 +45,28 @@ cargo add miniexcel 最低支援 Rust 1.85.0。 +### .NET 套件 + +本儲存庫也會建置預覽版 `MiniExcel.Rust` NuGet 套件。它依賴 MiniExcel v1(`1.46.0` 或更新的 +1.x 版本),重用其設定與 mapping 型別,並將透過 `MiniExcelRust` 發起的呼叫交給 Rust 原生程式庫執行。 + +```bash +dotnet add package MiniExcel.Rust --prerelease +``` + +```csharp +using MiniExcelLibs; +using MiniExcelLibs.OpenXml; + +var rows = MiniExcelRust.Query( + "book.xlsx", + useHeaderRow: true, + configuration: new OpenXmlConfiguration { IgnoreEmptyRows = true }); +``` + +`MiniExcel.Query` 使用原本的 managed 實作,`MiniExcelRust.Query` 使用 Rust 後端。可透過 +`./scripts/dotnet/Test-Package.ps1 -Rid win-x64` 建置並使用本機套件。 + ## 快速開始 ```rust diff --git a/dotnet/src/MiniExcel.Rust/MiniExcel.Rust.csproj b/dotnet/src/MiniExcel.Rust/MiniExcel.Rust.csproj new file mode 100644 index 0000000..6781bb6 --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/MiniExcel.Rust.csproj @@ -0,0 +1,61 @@ + + + + netstandard2.0;net8.0 + latest + enable + enable + MiniExcelLibs + MiniExcel.Rust + MiniExcel.Rust + 0.1.0-preview.1 + MiniExcel.Rust + MiniExcel-compatible spreadsheet APIs for .NET powered by MiniExcel for Rust. + Mini-Software + Mini-Software + excel;xlsx;csv;rust;native;streaming;miniexcel + Apache-2.0 + https://github.com/mini-software/MiniExcel-Rust + https://github.com/mini-software/MiniExcel-Rust + git + README.md + false + true + true + snupkg + $(MSBuildThisFileDirectory)..\..\..\target\nuget\native + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/MiniExcel.Rust/MiniExcelRust.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRust.cs new file mode 100644 index 0000000..fc65b4e --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/MiniExcelRust.cs @@ -0,0 +1,3209 @@ +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; + +/// +/// Provides XLSX queries backed by the native MiniExcel Rust library. +/// +public static class MiniExcelRust +{ + private const int BatchSize = 64; + + public static IAsyncEnumerable> QueryAsync( + string path, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null, + CancellationToken cancellationToken = default) + { + return ToAsyncEnumerable( + Query(path, useHeaderRow, sheetName, startCell, configuration), + cancellationToken); + } + + public static IAsyncEnumerable QueryAsync( + string path, + string? sheetName = null, + string startCell = "A1", + bool treatHeaderAsData = false, + MiniExcelRustReadOptions? configuration = null, + CancellationToken cancellationToken = default) + where T : class, new() + { + return ToAsyncEnumerable( + Query(path, sheetName, startCell, treatHeaderAsData, configuration), + cancellationToken); + } + + public static IAsyncEnumerable> QueryAsync( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false, + CancellationToken cancellationToken = default) => + ToAsyncEnumerable( + Query(stream, useHeaderRow, sheetName, startCell, configuration, leaveOpen), + cancellationToken); + + public static IAsyncEnumerable QueryAsync( + Stream stream, + string? sheetName = null, + string startCell = "A1", + bool treatHeaderAsData = false, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false, + CancellationToken cancellationToken = default) + where T : class, new() => + ToAsyncEnumerable( + Query(stream, sheetName, startCell, treatHeaderAsData, configuration, leaveOpen), + cancellationToken); + + public static IAsyncEnumerable> QueryRangeAsync( + string path, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + MiniExcelRustReadOptions? configuration = null, + CancellationToken cancellationToken = default) + { + return ToAsyncEnumerable( + QueryRange(path, useHeaderRow, sheetName, startCell, endCell, configuration), + cancellationToken); + } + + public static IAsyncEnumerable> QueryRangeAsync( + Stream stream, + bool useHeaderRow, + string? sheetName, + int startRowIndex, + int startColumnIndex, + int? endRowIndex = null, + int? endColumnIndex = null, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false, + CancellationToken cancellationToken = default) => + ToAsyncEnumerable( + QueryRange( + stream, + useHeaderRow, + sheetName, + startRowIndex, + startColumnIndex, + endRowIndex, + endColumnIndex, + configuration, + leaveOpen), + cancellationToken); + + public static IAsyncEnumerable> QueryRangeAsync( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false, + CancellationToken cancellationToken = default) => + ToAsyncEnumerable( + QueryRange(stream, useHeaderRow, sheetName, startCell, endCell, configuration, leaveOpen), + cancellationToken); + + public static IAsyncEnumerable> QueryTableAsync( + string path, + string? sheetName = null, + string tableName = "Table1", + CancellationToken cancellationToken = default) + { + return ToAsyncEnumerable(QueryTable(path, sheetName, tableName), cancellationToken); + } + + public static IAsyncEnumerable QueryTableAsync( + string path, + string? sheetName = null, + string tableName = "Table1", + CancellationToken cancellationToken = default) + where T : class, new() => + ToAsyncEnumerable(QueryTable(path, sheetName, tableName), cancellationToken); + + public static IAsyncEnumerable> QueryTableAsync( + Stream stream, + string? sheetName = null, + string tableName = "Table1", + bool leaveOpen = false, + CancellationToken cancellationToken = default) => + ToAsyncEnumerable(QueryTable(stream, sheetName, tableName, leaveOpen), cancellationToken); + + public static IAsyncEnumerable> QueryCsvAsync( + string path, + bool useHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null, + CancellationToken cancellationToken = default) + { + return ToAsyncEnumerable(QueryCsv(path, useHeaderRow, configuration), cancellationToken); + } + + public static IAsyncEnumerable QueryCsvAsync( + string path, + bool treatHeaderAsData = false, + MiniExcelRustCsvReadOptions? configuration = null, + CancellationToken cancellationToken = default) + where T : class, new() => + ToAsyncEnumerable(QueryCsv(path, treatHeaderAsData, configuration), cancellationToken); + + public static IEnumerable Query( + string path, + string? sheetName = null, + string startCell = "A1", + bool treatHeaderAsData = false, + MiniExcelRustReadOptions? configuration = null) + where T : class, new() + { + return MiniExcelRustMapper.Map( + Query(path, !treatHeaderAsData, sheetName, startCell, configuration), + configuration?.Culture, + configuration?.DynamicColumns as IReadOnlyDictionary); + } + + public static IEnumerable Query( + Stream stream, + string? sheetName = null, + string startCell = "A1", + bool treatHeaderAsData = false, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + where T : class, new() + { + return MiniExcelRustMapper.Map( + Query(stream, !treatHeaderAsData, sheetName, startCell, configuration, leaveOpen), + configuration?.Culture, + configuration?.DynamicColumns as IReadOnlyDictionary); + } + + public static IEnumerable QueryRange( + string path, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + bool treatHeaderAsData = false, + MiniExcelRustReadOptions? configuration = null) + where T : class, new() + { + return MiniExcelRustMapper.Map( + QueryRange(path, !treatHeaderAsData, sheetName, startCell, endCell, configuration), + configuration?.Culture, + configuration?.DynamicColumns as IReadOnlyDictionary); + } + + public static IEnumerable QueryRange( + Stream stream, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + bool treatHeaderAsData = false, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + where T : class, new() => + MiniExcelRustMapper.Map( + QueryRange( + stream, + !treatHeaderAsData, + sheetName, + startCell, + endCell, + configuration, + leaveOpen), + configuration?.Culture, + configuration?.DynamicColumns as IReadOnlyDictionary); + + public static IEnumerable QueryTable( + string path, + string? sheetName = null, + string tableName = "Table1") + where T : class, new() + { + return MiniExcelRustMapper.Map(QueryTable(path, sheetName, tableName)); + } + + public static IEnumerable QueryTable( + Stream stream, + string? sheetName = null, + string tableName = "Table1", + bool leaveOpen = false) + where T : class, new() => + MiniExcelRustMapper.Map(QueryTable(stream, sheetName, tableName, leaveOpen)); + + public static IEnumerable QueryCsv( + string path, + bool treatHeaderAsData = false, + MiniExcelRustCsvReadOptions? configuration = null) + where T : class, new() + { + return MiniExcelRustMapper.Map( + QueryCsv(path, !treatHeaderAsData, configuration), + configuration?.Culture, + configuration?.DynamicColumns as IReadOnlyDictionary); + } + + public static IEnumerable QueryCsv( + Stream stream, + bool treatHeaderAsData = false, + MiniExcelRustCsvReadOptions? configuration = null, + bool leaveOpen = false) + where T : class, new() + { + return MiniExcelRustMapper.Map( + QueryCsv(stream, !treatHeaderAsData, configuration, leaveOpen), + configuration?.Culture, + configuration?.DynamicColumns as IReadOnlyDictionary); + } + + /// + /// 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); + } + + public static IDictionary ReadMapped( + string path, + IReadOnlyDictionary mapping, + string? sheetName = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (mapping is null || mapping.Count == 0) + throw new ArgumentException("At least one cell mapping is required.", nameof(mapping)); + + EnsureAbiVersion(); + var mappingFrame = EncodeMapping(mapping); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + var mappingHandle = GCHandle.Alloc(mappingFrame, GCHandleType.Pinned); + try + { + var result = NativeMethods.ReadMapped( + nativePath.Pointer, + nativeSheetName.Pointer, + mappingHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)mappingFrame.Length, + out var rawHandle, + out var data, + out var length); + if (result < 0) + throw CreateNativeException(result); + using var handle = new NativeBufferHandle(rawHandle); + var frame = new byte[checked((int)length.ToUInt64())]; + Marshal.Copy(data, frame, 0, frame.Length); + return DecodeBatch(frame).Single(); + } + finally + { + mappingHandle.Free(); + } + } + + public static T ReadMapped( + string path, + IReadOnlyDictionary mapping, + string? sheetName = null) + where T : class, new() => + MiniExcelRustMapper.Map(new[] { ReadMapped(path, mapping, sheetName) }).Single(); + + public static T ReadMapped( + Stream stream, + IReadOnlyDictionary mapping, + string? sheetName = null, + bool leaveOpen = false) + where T : class, new() => + UseStagedStream(stream, leaveOpen, path => ReadMapped(path, mapping, sheetName)); + + /// + /// Returns threaded comments, replies, and legacy notes from an XLSX worksheet. + /// + public static MiniExcelRustCommentResult RetrieveComments(string path, string? sheetName = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + + EnsureAbiVersion(); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + var result = NativeMethods.GetComments( + nativePath.Pointer, + 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. + /// + public static DataTable QueryAsDataTable( + string path, + bool hasHeaderRow = true, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + + var fullPath = Path.GetFullPath(path); + var columns = GetColumnNames(fullPath, hasHeaderRow, sheetName, startCell); + var rows = Query(fullPath, hasHeaderRow, sheetName, startCell, configuration); + return CreateDataTable(columns, rows); + } + + /// + /// Materializes an XLSX stream query as a DataTable. + /// + public static DataTable QueryAsDataTable( + Stream stream, + bool hasHeaderRow = true, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + { + return UseStagedStream( + stream, + leaveOpen, + path => QueryAsDataTable(path, hasHeaderRow, sheetName, startCell, configuration)); + } + + /// + /// Returns a DataReader over a materialized Rust-backed XLSX query. + /// + public static IDataReader GetReader( + string path, + bool hasHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null) + { + if (sheetName is not null) + return QueryAsDataTable(path, hasHeaderRow, sheetName, startCell, configuration).CreateDataReader(); + + var dataSet = new DataSet(); + foreach (var name in GetSheetNames(path)) + { + var table = QueryAsDataTable(path, hasHeaderRow, name, startCell, configuration); + table.TableName = name; + dataSet.Tables.Add(table); + } + return dataSet.CreateDataReader(); + } + + /// + /// Returns a DataReader over a materialized Rust-backed XLSX stream query. + /// + public static IDataReader GetReader( + Stream stream, + bool hasHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + { + return UseStagedStream( + stream, + leaveOpen, + path => GetReader(path, hasHeaderRow, sheetName, startCell, configuration)); + } + + /// + /// Streams rows from an XLSX file through the native Rust query engine. + /// + public static IEnumerable> Query( + string path, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (string.IsNullOrWhiteSpace(startCell)) + throw new ArgumentException("The start cell is required.", nameof(startCell)); + + return QueryIterator(Path.GetFullPath(path), useHeaderRow, sheetName, startCell, null, configuration); + } + + /// + /// Streams rows from an XLSX stream through the native Rust query engine. + /// + public static IEnumerable> Query( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + { + ValidateReadableStream(stream); + if (string.IsNullOrWhiteSpace(startCell)) + throw new ArgumentException("The start cell is required.", nameof(startCell)); + + return QueryStreamIterator(stream, useHeaderRow, sheetName, startCell, null, configuration, leaveOpen); + } + + /// + /// Streams rows from an inclusive XLSX cell range through the native Rust query engine. + /// + public static IEnumerable> QueryRange( + string path, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + MiniExcelRustReadOptions? configuration = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (string.IsNullOrWhiteSpace(startCell)) + throw new ArgumentException("The start cell is required.", nameof(startCell)); + if (endCell is not null && string.IsNullOrWhiteSpace(endCell)) + throw new ArgumentException("The end cell cannot be empty.", nameof(endCell)); + + return QueryIterator(Path.GetFullPath(path), useHeaderRow, sheetName, startCell, endCell, configuration); + } + + /// + /// Streams rows from an inclusive XLSX range in a stream through the native Rust query engine. + /// + public static IEnumerable> QueryRange( + Stream stream, + bool useHeaderRow = false, + string? sheetName = null, + string startCell = "A1", + string? endCell = null, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + { + ValidateReadableStream(stream); + if (string.IsNullOrWhiteSpace(startCell)) + throw new ArgumentException("The start cell is required.", nameof(startCell)); + if (endCell is not null && string.IsNullOrWhiteSpace(endCell)) + throw new ArgumentException("The end cell cannot be empty.", nameof(endCell)); + + return QueryStreamIterator(stream, useHeaderRow, sheetName, startCell, endCell, configuration, leaveOpen); + } + + public static IEnumerable> QueryRange( + string path, + bool useHeaderRow, + string? sheetName, + int startRowIndex, + int startColumnIndex, + int? endRowIndex = null, + int? endColumnIndex = null, + MiniExcelRustReadOptions? configuration = null) + { + var startCell = ToCellReference(startRowIndex, startColumnIndex); + var endCell = endRowIndex.HasValue || endColumnIndex.HasValue + ? ToCellReference(endRowIndex ?? 1_048_576, endColumnIndex ?? 16_384) + : null; + return QueryRange(path, useHeaderRow, sheetName, startCell, endCell, configuration); + } + + public static IEnumerable> QueryRange( + Stream stream, + bool useHeaderRow, + string? sheetName, + int startRowIndex, + int startColumnIndex, + int? endRowIndex = null, + int? endColumnIndex = null, + MiniExcelRustReadOptions? configuration = null, + bool leaveOpen = false) + { + var startCell = ToCellReference(startRowIndex, startColumnIndex); + var endCell = endRowIndex.HasValue || endColumnIndex.HasValue + ? ToCellReference(endRowIndex ?? 1_048_576, endColumnIndex ?? 16_384) + : null; + return QueryRange( + stream, + useHeaderRow, + sheetName, + startCell, + endCell, + configuration, + leaveOpen); + } + + public static IAsyncEnumerable> QueryRangeAsync( + string path, + bool useHeaderRow, + string? sheetName, + int startRowIndex, + int startColumnIndex, + int? endRowIndex = null, + int? endColumnIndex = null, + MiniExcelRustReadOptions? configuration = null, + CancellationToken cancellationToken = default) => + ToAsyncEnumerable( + QueryRange( + path, + useHeaderRow, + sheetName, + startRowIndex, + startColumnIndex, + endRowIndex, + endColumnIndex, + configuration), + cancellationToken); + + /// + /// Streams rows from a named OpenXML table. + /// + public static IEnumerable> QueryTable( + string path, + string? sheetName = null, + string tableName = "Table1") + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (string.IsNullOrWhiteSpace(tableName)) + throw new ArgumentException("The table name is required.", nameof(tableName)); + + return QueryTableIterator(Path.GetFullPath(path), sheetName, tableName); + } + + /// + /// Streams rows from a named OpenXML table in a stream. + /// + public static IEnumerable> QueryTable( + Stream stream, + string? sheetName = null, + string tableName = "Table1", + bool leaveOpen = false) + { + ValidateReadableStream(stream); + if (string.IsNullOrWhiteSpace(tableName)) + throw new ArgumentException("The table name is required.", nameof(tableName)); + + return QueryTableStreamIterator(stream, sheetName, tableName, leaveOpen); + } + + /// + /// Streams rows from a CSV file through the native Rust query engine. + /// + public static IEnumerable> QueryCsv( + string path, + bool useHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + ValidateCsvConfiguration(configuration); + + return QueryCsvIterator(Path.GetFullPath(path), useHeaderRow, configuration); + } + + /// + /// Streams rows from a CSV stream through the native Rust query engine. + /// + public static IEnumerable> QueryCsv( + Stream stream, + bool useHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null, + bool leaveOpen = false) + { + ValidateReadableStream(stream); + ValidateCsvConfiguration(configuration); + + return QueryCsvStreamIterator(stream, useHeaderRow, configuration, leaveOpen); + } + + /// + /// Returns selected column names from a CSV file. + /// + public static List GetCsvColumnNames( + string path, + bool useHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + ValidateCsvConfiguration(configuration); + EnsureAbiVersion(); + configuration ??= new MiniExcelRustCsvReadOptions(); + + using var nativePath = new Utf8String(Path.GetFullPath(path)); + var result = NativeMethods.GetCsvColumns( + nativePath.Pointer, + useHeaderRow ? (byte)1 : (byte)0, + (byte)configuration.Delimiter, + (byte)configuration.Encoding, + configuration.ReadEmptyStringAsNull ? (byte)1 : (byte)0, + configuration.TrimColumnNames ? (byte)1 : (byte)0, + out var rawHandle, + out var data, + out var length); + if (result < 0) + throw CreateNativeException(result); + + using var handle = new NativeBufferHandle(rawHandle); + var byteLength = checked((int)length.ToUInt64()); + var frame = new byte[byteLength]; + Marshal.Copy(data, frame, 0, byteLength); + return DecodeStrings(frame); + } + + /// + /// Returns selected column names from a CSV stream. + /// + public static List GetCsvColumnNames( + Stream stream, + bool useHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null, + bool leaveOpen = false) + { + return UseStagedStream( + stream, + leaveOpen, + path => GetCsvColumnNames(path, useHeaderRow, configuration)); + } + + /// + /// Asynchronously returns selected column names from a CSV file. + /// + public static Task> GetCsvColumnNamesAsync( + string path, + bool useHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null, + CancellationToken cancellationToken = default) + { + return Task.Run( + () => GetCsvColumnNames(path, useHeaderRow, configuration), + cancellationToken); + } + + /// + /// Materializes a CSV query as a DataTable. + /// + public static DataTable QueryCsvAsDataTable( + string path, + bool hasHeaderRow = true, + MiniExcelRustCsvReadOptions? configuration = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + + var fullPath = Path.GetFullPath(path); + var columns = GetCsvColumnNames(fullPath, hasHeaderRow, configuration); + var rows = QueryCsv(fullPath, hasHeaderRow, configuration); + return CreateDataTable(columns, rows); + } + + /// + /// Materializes a CSV stream query as a DataTable. + /// + public static DataTable QueryCsvAsDataTable( + Stream stream, + bool hasHeaderRow = true, + MiniExcelRustCsvReadOptions? configuration = null, + bool leaveOpen = false) + { + return UseStagedStream( + stream, + leaveOpen, + path => QueryCsvAsDataTable(path, hasHeaderRow, configuration)); + } + + /// + /// Returns a DataReader over a materialized Rust-backed CSV query. + /// + public static IDataReader GetCsvReader( + string path, + bool hasHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null) + { + return QueryCsvAsDataTable(path, hasHeaderRow, configuration).CreateDataReader(); + } + + /// + /// Returns a DataReader over a materialized Rust-backed CSV stream query. + /// + public static IDataReader GetCsvReader( + Stream stream, + bool hasHeaderRow = false, + MiniExcelRustCsvReadOptions? configuration = null, + bool leaveOpen = false) + { + return QueryCsvAsDataTable(stream, hasHeaderRow, configuration, leaveOpen).CreateDataReader(); + } + + /// + /// Creates a single-sheet XLSX workbook from dynamic rows. + /// + public static int SaveAs( + string path, + IEnumerable> rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool overwriteFile = false, + IProgress? progress = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (string.IsNullOrWhiteSpace(sheetName)) + throw new ArgumentException("The sheet name is required.", nameof(sheetName)); + + EnsureAbiVersion(); + var materializedRows = rows.ToList(); + var frame = EncodeRows(materializedRows); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + var frameHandle = GCHandle.Alloc(frame, GCHandleType.Pinned); + try + { + var result = NativeMethods.SaveAs( + nativePath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + printHeader ? (byte)1 : (byte)0, + nativeSheetName.Pointer, + overwriteFile ? (byte)1 : (byte)0, + out var rowCount); + if (result < 0) + throw CreateNativeException(result); + ReportProgress(progress, materializedRows); + return checked((int)rowCount); + } + finally + { + frameHandle.Free(); + } + } + + public static int SaveAs( + string path, + IEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool overwriteFile = false, + IProgress? progress = null) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (rows is IEnumerable> dynamicRows) + return SaveAs(path, dynamicRows, printHeader, sheetName, overwriteFile, progress); + return SaveAs( + path, + MiniExcelRustMapper.ToRows(rows), + printHeader, + sheetName, + overwriteFile, + progress); + } + + public static int SaveAs( + Stream stream, + IEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool leaveOpen = false, + IProgress? progress = null) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (rows is IEnumerable> dynamicRows) + return SaveAs(stream, dynamicRows, printHeader, sheetName, leaveOpen, progress); + return SaveAs(stream, MiniExcelRustMapper.ToRows(rows), printHeader, sheetName, leaveOpen, progress); + } + + public static async Task SaveAsAsync( + string path, + IAsyncEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool overwriteFile = false, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + cancellationToken.ThrowIfCancellationRequested(); + var spoolPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-spool-{Guid.NewGuid():N}.bin"); + try + { + List? schema = null; + long cellCount = 0; + using (var spool = new FileStream( + spoolPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 81920, + useAsync: true)) + { + await foreach (var value in rows.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + var row = value is IDictionary dynamicRow + ? dynamicRow + : MiniExcelRustMapper.ToRows(new[] { value }).Single(); + schema ??= row.Keys.ToList(); + cellCount += row.Count; + var frame = EncodeRows(new[] { row }); + var length = BitConverter.GetBytes(checked((uint)frame.Length)); + await spool.WriteAsync(length, 0, length.Length, cancellationToken).ConfigureAwait(false); + await spool.WriteAsync(frame, 0, frame.Length, cancellationToken).ConfigureAwait(false); + } + } + if (schema is null) + throw new InvalidOperationException("Async export requires at least one row to infer its schema."); + + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + schema, + sheetName, + overwriteFile, + printHeader + }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSpoolPath = new Utf8String(spoolPath); + using var nativeCancellation = NativeCancellationHandle.Create(); + using var registration = cancellationToken.Register( + static state => NativeMethods.Cancel((NativeCancellationHandle)state!), + nativeCancellation); + var payloadHandle = GCHandle.Alloc(payload, GCHandleType.Pinned); + try + { + var nativeResult = await Task.Run(() => + { + var result = NativeMethods.SaveAsSpooledAsync( + nativePath.Pointer, + nativeSpoolPath.Pointer, + payloadHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)payload.Length, + nativeCancellation, + out var rowCount); + return (Result: result, RowCount: rowCount); + }).ConfigureAwait(false); + if (nativeResult.Result < 0) + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + throw CreateNativeException(nativeResult.Result); + } + if (progress is not null) + { + for (long index = 0; index < cellCount; index++) + progress.Report(1); + } + return checked((int)nativeResult.RowCount); + } + finally + { + payloadHandle.Free(); + } + } + finally + { + DeleteTemporaryFile(spoolPath); + } + } + + public static async Task SaveAsAsync( + Stream stream, + IAsyncEnumerable rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool leaveOpen = false, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + ValidateWritableStream(stream); + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + try + { + var count = await SaveAsAsync( + temporaryPath, + rows, + printHeader, + sheetName, + false, + progress, + cancellationToken).ConfigureAwait(false); + CopyFileToStream(temporaryPath, stream); + return count; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static int[] SaveAsSheets( + string path, + IEnumerable>>> sheets, + bool printHeader = true, + bool overwriteFile = false) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (sheets is null) + throw new ArgumentNullException(nameof(sheets)); + + EnsureAbiVersion(); + var frame = EncodeSheets(sheets); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + var frameHandle = GCHandle.Alloc(frame, GCHandleType.Pinned); + try + { + var result = NativeMethods.SaveAsSheets( + nativePath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + printHeader ? (byte)1 : (byte)0, + overwriteFile ? (byte)1 : (byte)0, + out var rawHandle, + out var data, + out var length); + if (result < 0) + throw CreateNativeException(result); + using var handle = new NativeBufferHandle(rawHandle); + var byteLength = checked((int)length.ToUInt64()); + var resultFrame = new byte[byteLength]; + Marshal.Copy(data, resultFrame, 0, byteLength); + var reader = new FrameReader(resultFrame); + var count = reader.ReadLength(); + var rowCounts = new int[count]; + for (var index = 0; index < count; index++) + rowCounts[index] = reader.ReadLength(); + reader.EnsureComplete(); + return rowCounts; + } + finally + { + frameHandle.Free(); + } + } + + public static int[] SaveAsSheets( + Stream stream, + IEnumerable>>> sheets, + bool printHeader = true, + bool leaveOpen = false) + { + ValidateWritableStream(stream); + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + try + { + var rowCounts = SaveAsSheets(temporaryPath, sheets, printHeader); + CopyFileToStream(temporaryPath, stream); + return rowCounts; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static int SaveAsWithSchema( + string path, + IReadOnlyList schema, + IEnumerable> rows, + MiniExcelRustWriteOptions? options = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (schema is null) + throw new ArgumentNullException(nameof(schema)); + if (schema.Count == 0 || schema.Any(string.IsNullOrWhiteSpace)) + throw new ArgumentException("The schema must contain at least one named column.", nameof(schema)); + if (schema.Distinct(StringComparer.Ordinal).Count() != schema.Count) + throw new ArgumentException("Schema column names must be unique.", nameof(schema)); + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + options ??= new MiniExcelRustWriteOptions(); + var formulaColumns = options.DynamicColumns + .Where(column => column.Value.IsFormula) + .Select(column => string.IsNullOrWhiteSpace(column.Value.Name) ? column.Key : column.Value.Name!) + .ToArray(); + + EnsureAbiVersion(); + var frame = EncodeRows(rows); + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + schema, + options.SheetName, + options.OverwriteFile, + options.PrintHeader, + options.AutoFilter, + options.RightToLeft, + options.AutoWidth, + options.WrapCellContents, + horizontalAlignment = options.HorizontalAlignment.ToString().ToLowerInvariant(), + verticalAlignment = options.VerticalAlignment.ToString().ToLowerInvariant(), + tableStyle = options.TableStyle.ToString().ToLowerInvariant(), + options.HeaderWrapText, + options.HeaderBackgroundColor, + headerHorizontalAlignment = options.HeaderHorizontalAlignment.ToString().ToLowerInvariant(), + headerVerticalAlignment = options.HeaderVerticalAlignment.ToString().ToLowerInvariant(), + options.MinWidth, + options.MaxWidth, + options.FreezeRowCount, + options.FreezeColumnCount, + options.DateFormat, + options.TimeFormat, + options.DateTimeFormat, + options.DurationFormat, + options.ColumnFormats, + options.ColumnWidths, + options.HiddenColumns, + formulaColumns + }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + var frameHandle = GCHandle.Alloc(frame, GCHandleType.Pinned); + var payloadHandle = GCHandle.Alloc(payload, GCHandleType.Pinned); + try + { + var result = NativeMethods.SaveAsConfigured( + nativePath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + payloadHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)payload.Length, + out var rowCount); + if (result < 0) + throw CreateNativeException(result); + return checked((int)rowCount); + } + finally + { + payloadHandle.Free(); + frameHandle.Free(); + } + } + + public static int SaveAs( + string path, + IEnumerable rows, + MiniExcelRustWriteOptions options) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (options is null) + throw new ArgumentNullException(nameof(options)); + var dynamicColumns = options.DynamicColumns as IReadOnlyDictionary; + var dynamicRows = MiniExcelRustMapper.ToRows(rows, dynamicColumns).ToList(); + if (dynamicRows.Count == 0) + throw new ArgumentException("Typed configured export requires at least one row.", nameof(rows)); + var schema = dynamicRows[0].Keys.ToList(); + return SaveAsWithSchema(path, schema, dynamicRows, options); + } + + /// + /// Creates a single-sheet XLSX workbook and copies it to a writable stream. + /// + public static int SaveAs( + Stream stream, + IEnumerable> rows, + bool printHeader = true, + string sheetName = "Sheet1", + bool leaveOpen = false, + IProgress? progress = null) + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + if (!stream.CanWrite) + throw new ArgumentException("The stream must be writable.", nameof(stream)); + + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + try + { + var rowCount = SaveAs(temporaryPath, rows, printHeader, sheetName, progress: progress); + using var input = File.OpenRead(temporaryPath); + input.CopyTo(stream); + return rowCount; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + /// + /// Creates a CSV file from dynamic rows. + /// + public static int SaveAsCsv( + string path, + IEnumerable> rows, + MiniExcelRustCsvWriteOptions? configuration = null) + { + return WriteCsv(path, rows, configuration, append: false); + } + + public static int SaveAsCsv( + string path, + IEnumerable rows, + MiniExcelRustCsvWriteOptions? configuration = null) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (rows is IEnumerable> dynamicRows) + return SaveAsCsv(path, dynamicRows, configuration); + return SaveAsCsv(path, MiniExcelRustMapper.ToRows(rows), configuration); + } + + public static int SaveAsCsv( + Stream stream, + IEnumerable rows, + MiniExcelRustCsvWriteOptions? configuration = null, + bool leaveOpen = false) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (rows is IEnumerable> dynamicRows) + return SaveAsCsv(stream, dynamicRows, configuration, leaveOpen); + return SaveAsCsv(stream, MiniExcelRustMapper.ToRows(rows), configuration, leaveOpen); + } + + public static async Task SaveAsCsvAsync( + string path, + IAsyncEnumerable rows, + MiniExcelRustCsvWriteOptions? configuration = null, + CancellationToken cancellationToken = default) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + cancellationToken.ThrowIfCancellationRequested(); + configuration ??= new MiniExcelRustCsvWriteOptions(); + var spoolPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-csv-spool-{Guid.NewGuid():N}.bin"); + try + { + List? schema = null; + using (var spool = new FileStream( + spoolPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 81920, + useAsync: true)) + { + await foreach (var value in rows.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + var row = value is IDictionary dynamicRow + ? dynamicRow + : MiniExcelRustMapper.ToRows(new[] { value }).Single(); + schema ??= row.Keys.ToList(); + var frame = EncodeRows(new[] { row }); + var length = BitConverter.GetBytes(checked((uint)frame.Length)); + await spool.WriteAsync(length, 0, length.Length, cancellationToken).ConfigureAwait(false); + await spool.WriteAsync(frame, 0, frame.Length, cancellationToken).ConfigureAwait(false); + } + } + if (schema is null) + throw new InvalidOperationException("Async CSV export requires at least one row to infer its schema."); + var payload = JsonSerializer.SerializeToUtf8Bytes(new + { + schema, + delimiter = (byte)configuration.Delimiter, + encoding = (byte)configuration.Encoding, + configuration.WriteBom, + configuration.PrintHeader, + configuration.OverwriteFile + }, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSpoolPath = new Utf8String(spoolPath); + using var nativeCancellation = NativeCancellationHandle.Create(); + using var registration = cancellationToken.Register( + static state => NativeMethods.Cancel((NativeCancellationHandle)state!), + nativeCancellation); + var payloadHandle = GCHandle.Alloc(payload, GCHandleType.Pinned); + try + { + var nativeResult = await Task.Run(() => + { + var result = NativeMethods.SaveCsvSpooledAsync( + nativePath.Pointer, + nativeSpoolPath.Pointer, + payloadHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)payload.Length, + nativeCancellation, + out var rowCount); + return (Result: result, RowCount: rowCount); + }).ConfigureAwait(false); + if (nativeResult.Result < 0) + { + if (cancellationToken.IsCancellationRequested) + throw new OperationCanceledException(cancellationToken); + throw CreateNativeException(nativeResult.Result); + } + return checked((int)nativeResult.RowCount); + } + finally + { + payloadHandle.Free(); + } + } + finally + { + DeleteTemporaryFile(spoolPath); + } + } + + /// + /// Appends dynamic rows to a CSV file without repeating its header. + /// + public static int AppendCsv( + string path, + IEnumerable> rows, + MiniExcelRustCsvWriteOptions? configuration = null) + { + return WriteCsv(path, rows, configuration, append: true); + } + + public static int AppendCsv( + string path, + IEnumerable rows, + MiniExcelRustCsvWriteOptions? configuration = null) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + return AppendCsv(path, MiniExcelRustMapper.ToRows(rows), configuration); + } + + public static int AppendCsv( + Stream stream, + IEnumerable rows, + MiniExcelRustCsvWriteOptions? configuration = null, + bool leaveOpen = false) + { + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + if (rows is IEnumerable> dynamicRows) + return AppendCsv(stream, dynamicRows, configuration, leaveOpen); + return AppendCsv(stream, MiniExcelRustMapper.ToRows(rows), configuration, leaveOpen); + } + + public static int SaveAsCsv( + Stream stream, + IEnumerable> rows, + MiniExcelRustCsvWriteOptions? configuration = null, + bool leaveOpen = false) + { + ValidateWritableStream(stream); + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.csv"); + try + { + configuration ??= new MiniExcelRustCsvWriteOptions(); + var rowCount = SaveAsCsv( + temporaryPath, + rows, + new MiniExcelRustCsvWriteOptions + { + Delimiter = configuration.Delimiter, + Encoding = configuration.Encoding, + WriteBom = configuration.WriteBom, + PrintHeader = configuration.PrintHeader, + OverwriteFile = false + }); + CopyFileToStream(temporaryPath, stream); + return rowCount; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static int AppendCsv( + Stream stream, + IEnumerable> rows, + MiniExcelRustCsvWriteOptions? configuration = null, + bool leaveOpen = false) + { + ValidateReadableStream(stream); + ValidateWritableStream(stream); + if (!stream.CanSeek) + throw new ArgumentException("The stream must be seekable for CSV append.", nameof(stream)); + + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.csv"); + try + { + stream.Position = 0; + using (var output = File.Create(temporaryPath)) + stream.CopyTo(output); + var rowCount = AppendCsv(temporaryPath, rows, configuration); + CopyFileToStream(temporaryPath, stream); + return rowCount; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static void ConvertCsvToXlsx( + string csvPath, + string xlsxPath, + bool csvHasHeader = false) + { + var rows = QueryCsv(csvPath, csvHasHeader); + SaveAs(xlsxPath, rows, csvHasHeader); + } + + public static void ConvertCsvToXlsx( + Stream csvStream, + Stream xlsxStream, + bool csvHasHeader = false) + { + var rows = QueryCsv(csvStream, csvHasHeader, leaveOpen: true); + SaveAs(xlsxStream, rows, csvHasHeader, leaveOpen: true); + } + + public static Task ConvertCsvToXlsxAsync( + string csvPath, + string xlsxPath, + bool csvHasHeader = false, + CancellationToken cancellationToken = default) + { + return Task.Run(() => ConvertCsvToXlsx(csvPath, xlsxPath, csvHasHeader), cancellationToken); + } + + public static void ConvertXlsxToCsv( + string xlsxPath, + string csvPath, + bool xlsxHasHeader = true) + { + var rows = Query(xlsxPath, xlsxHasHeader); + SaveAsCsv( + csvPath, + rows, + new MiniExcelRustCsvWriteOptions { PrintHeader = xlsxHasHeader }); + } + + public static void ConvertXlsxToCsv( + Stream xlsxStream, + Stream csvStream, + bool xlsxHasHeader = true) + { + var rows = Query(xlsxStream, xlsxHasHeader, leaveOpen: true); + SaveAsCsv( + csvStream, + rows, + new MiniExcelRustCsvWriteOptions { PrintHeader = xlsxHasHeader }, + leaveOpen: true); + } + + public static Task ConvertXlsxToCsvAsync( + string xlsxPath, + string csvPath, + bool xlsxHasHeader = true, + CancellationToken cancellationToken = default) + { + return Task.Run(() => ConvertXlsxToCsv(xlsxPath, csvPath, xlsxHasHeader), cancellationToken); + } + + public static void RenameSheet(string path, string sheetName, string newSheetName) + { + ValidatePathAndSheet(path, sheetName); + if (string.IsNullOrWhiteSpace(newSheetName)) + throw new ArgumentException("The new sheet name is required.", nameof(newSheetName)); + EnsureAbiVersion(); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + using var nativeNewSheetName = new Utf8String(newSheetName); + var result = NativeMethods.RenameSheet(nativePath.Pointer, nativeSheetName.Pointer, nativeNewSheetName.Pointer); + if (result < 0) + throw CreateNativeException(result); + } + + public static void ReorderSheet(string path, string sheetName, int newSheetIndex) + { + ValidatePathAndSheet(path, sheetName); + EnsureAbiVersion(); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + var result = NativeMethods.ReorderSheet(nativePath.Pointer, nativeSheetName.Pointer, newSheetIndex); + if (result < 0) + throw CreateNativeException(result); + } + + public static void SetSheetVisibility( + string path, + string sheetName, + MiniExcelRustSheetState visibility) + { + ValidatePathAndSheet(path, sheetName); + if (visibility is < MiniExcelRustSheetState.Visible or > MiniExcelRustSheetState.VeryHidden) + throw new ArgumentOutOfRangeException(nameof(visibility)); + EnsureAbiVersion(); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + var result = NativeMethods.SetSheetVisibility(nativePath.Pointer, nativeSheetName.Pointer, (byte)visibility); + if (result < 0) + throw CreateNativeException(result); + } + + public static int InsertSheet( + string path, + IEnumerable> rows, + string sheetName, + MiniExcelRustInsertOptions? options = null) + { + ValidatePathAndSheet(path, sheetName); + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + options ??= new MiniExcelRustInsertOptions(); + EnsureAbiVersion(); + + var frame = EncodeRows(rows); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + using var nativeSheetName = new Utf8String(sheetName); + var frameHandle = GCHandle.Alloc(frame, GCHandleType.Pinned); + try + { + var result = NativeMethods.InsertSheet( + nativePath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + nativeSheetName.Pointer, + options.PrintHeader ? (byte)1 : (byte)0, + options.ReplaceExistingSheet ? (byte)1 : (byte)0, + options.RemoveSupportedRelationships ? (byte)1 : (byte)0, + out var rowCount); + if (result < 0) + throw CreateNativeException(result); + return checked((int)rowCount); + } + finally + { + frameHandle.Free(); + } + } + + public static int InsertSheet( + Stream stream, + IEnumerable> rows, + string sheetName, + MiniExcelRustInsertOptions? options = null, + bool leaveOpen = false) + { + ValidateReadableStream(stream); + ValidateWritableStream(stream); + if (!stream.CanSeek) + throw new ArgumentException("The stream must be seekable for worksheet insertion.", nameof(stream)); + stream.Position = 0; + var temporaryPath = StageStream(stream); + try + { + var count = InsertSheet(temporaryPath, rows, sheetName, options); + CopyFileToStream(temporaryPath, stream); + return count; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static int CopyAndAddSheet( + string sourcePath, + string destinationPath, + IEnumerable> rows, + string sheetName, + MiniExcelRustInsertOptions? options = null) + { + ValidatePathAndSheet(sourcePath, sheetName); + if (string.IsNullOrWhiteSpace(destinationPath)) + throw new ArgumentException("The destination path is required.", nameof(destinationPath)); + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + options ??= new MiniExcelRustInsertOptions(); + EnsureAbiVersion(); + + var frame = EncodeRows(rows); + using var nativeSourcePath = new Utf8String(Path.GetFullPath(sourcePath)); + using var nativeDestinationPath = new Utf8String(Path.GetFullPath(destinationPath)); + using var nativeSheetName = new Utf8String(sheetName); + var frameHandle = GCHandle.Alloc(frame, GCHandleType.Pinned); + try + { + var result = NativeMethods.CopyAndAddSheet( + nativeSourcePath.Pointer, + nativeDestinationPath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + nativeSheetName.Pointer, + options.PrintHeader ? (byte)1 : (byte)0, + options.ReplaceExistingSheet ? (byte)1 : (byte)0, + options.RemoveSupportedRelationships ? (byte)1 : (byte)0, + options.OverwriteDestination ? (byte)1 : (byte)0, + out var rowCount); + if (result < 0) + throw CreateNativeException(result); + return checked((int)rowCount); + } + finally + { + frameHandle.Free(); + } + } + + public static void FillTemplate( + string destinationPath, + string templatePath, + object value, + bool overwriteFile = false, + bool ignoreMissingVariables = true) + { + if (string.IsNullOrWhiteSpace(destinationPath)) + throw new ArgumentException("The destination path is required.", nameof(destinationPath)); + if (string.IsNullOrWhiteSpace(templatePath)) + throw new ArgumentException("The template path is required.", nameof(templatePath)); + if (value is null) + throw new ArgumentNullException(nameof(value)); + + EnsureAbiVersion(); + var json = JsonSerializer.SerializeToUtf8Bytes(value, value.GetType()); + using var nativeDestinationPath = new Utf8String(Path.GetFullPath(destinationPath)); + using var nativeTemplatePath = new Utf8String(Path.GetFullPath(templatePath)); + var jsonHandle = GCHandle.Alloc(json, GCHandleType.Pinned); + try + { + var result = NativeMethods.FillTemplate( + nativeDestinationPath.Pointer, + nativeTemplatePath.Pointer, + jsonHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)json.Length, + overwriteFile ? (byte)1 : (byte)0, + ignoreMissingVariables ? (byte)1 : (byte)0); + if (result < 0) + throw CreateNativeException(result); + } + finally + { + jsonHandle.Free(); + } + } + + internal static void FillMappedTemplateCore( + string destinationPath, + string templatePath, + byte[] payload, + bool overwriteFile) + { + if (string.IsNullOrWhiteSpace(destinationPath)) + throw new ArgumentException("The destination path is required.", nameof(destinationPath)); + if (string.IsNullOrWhiteSpace(templatePath)) + throw new ArgumentException("The template path is required.", nameof(templatePath)); + if (payload is null) + throw new ArgumentNullException(nameof(payload)); + + EnsureAbiVersion(); + using var nativeDestinationPath = new Utf8String(Path.GetFullPath(destinationPath)); + using var nativeTemplatePath = new Utf8String(Path.GetFullPath(templatePath)); + var payloadHandle = GCHandle.Alloc(payload, GCHandleType.Pinned); + try + { + var result = NativeMethods.FillMappedTemplate( + nativeDestinationPath.Pointer, + nativeTemplatePath.Pointer, + payloadHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)payload.Length, + overwriteFile ? (byte)1 : (byte)0); + if (result < 0) + throw CreateNativeException(result); + } + finally + { + payloadHandle.Free(); + } + } + + public static void FillTemplate( + string destinationPath, + Stream templateStream, + object value, + bool overwriteFile = false, + bool ignoreMissingVariables = true, + bool leaveTemplateOpen = false) + { + _ = UseStagedStream(templateStream, leaveTemplateOpen, templatePath => + { + FillTemplate(destinationPath, templatePath, value, overwriteFile, ignoreMissingVariables); + return 0; + }); + } + + public static void FillTemplate( + string destinationPath, + byte[] templateBytes, + object value, + bool overwriteFile = false, + bool ignoreMissingVariables = true) + { + if (templateBytes is null) + throw new ArgumentNullException(nameof(templateBytes)); + using var templateStream = new MemoryStream(templateBytes, writable: false); + FillTemplate( + destinationPath, + templateStream, + value, + overwriteFile, + ignoreMissingVariables, + leaveTemplateOpen: false); + } + + public static void FillTemplate( + Stream destinationStream, + string templatePath, + object value, + bool ignoreMissingVariables = true, + bool leaveOpen = false) + { + ValidateWritableStream(destinationStream); + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + try + { + FillTemplate(temporaryPath, templatePath, value, false, ignoreMissingVariables); + CopyFileToStream(temporaryPath, destinationStream); + } + finally + { + if (!leaveOpen) + destinationStream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static void FillTemplate( + Stream destinationStream, + Stream templateStream, + object value, + bool ignoreMissingVariables = true, + bool leaveOpen = false, + bool leaveTemplateOpen = false) + { + _ = UseStagedStream(templateStream, leaveTemplateOpen, templatePath => + { + FillTemplate(destinationStream, templatePath, value, ignoreMissingVariables, leaveOpen); + return 0; + }); + } + + public static void FillTemplate( + Stream destinationStream, + byte[] templateBytes, + object value, + bool ignoreMissingVariables = true, + bool leaveOpen = false) + { + if (templateBytes is null) + throw new ArgumentNullException(nameof(templateBytes)); + using var templateStream = new MemoryStream(templateBytes, writable: false); + FillTemplate( + destinationStream, + templateStream, + value, + ignoreMissingVariables, + leaveOpen, + leaveTemplateOpen: false); + } + + public static void MergeSameCells( + string destinationPath, + string sourcePath, + bool overwriteFile = false) + { + if (string.IsNullOrWhiteSpace(destinationPath)) + throw new ArgumentException("The destination path is required.", nameof(destinationPath)); + if (string.IsNullOrWhiteSpace(sourcePath)) + throw new ArgumentException("The source path is required.", nameof(sourcePath)); + + EnsureAbiVersion(); + using var nativeDestinationPath = new Utf8String(Path.GetFullPath(destinationPath)); + using var nativeSourcePath = new Utf8String(Path.GetFullPath(sourcePath)); + var result = NativeMethods.MergeSameCells( + nativeDestinationPath.Pointer, + nativeSourcePath.Pointer, + overwriteFile ? (byte)1 : (byte)0); + if (result < 0) + throw CreateNativeException(result); + } + + public static void MergeSameCells( + string destinationPath, + Stream sourceStream, + bool overwriteFile = false, + bool leaveSourceOpen = false) + { + _ = UseStagedStream(sourceStream, leaveSourceOpen, sourcePath => + { + MergeSameCells(destinationPath, sourcePath, overwriteFile); + return 0; + }); + } + + public static void MergeSameCells( + Stream destinationStream, + string sourcePath, + bool leaveOpen = false) + { + ValidateWritableStream(destinationStream); + var temporaryPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + try + { + MergeSameCells(temporaryPath, sourcePath); + CopyFileToStream(temporaryPath, destinationStream); + } + finally + { + if (!leaveOpen) + destinationStream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + public static void MergeSameCells( + Stream destinationStream, + byte[] sourceBytes, + bool leaveOpen = false) + { + if (sourceBytes is null) + throw new ArgumentNullException(nameof(sourceBytes)); + using var sourceStream = new MemoryStream(sourceBytes, writable: false); + _ = UseStagedStream(sourceStream, false, sourcePath => + { + MergeSameCells(destinationStream, sourcePath, leaveOpen); + return 0; + }); + } + + public static void MergeSameCells( + Stream destinationStream, + Stream sourceStream, + bool leaveOpen = false, + bool leaveSourceOpen = false) + { + _ = UseStagedStream(sourceStream, leaveSourceOpen, sourcePath => + { + MergeSameCells(destinationStream, sourcePath, leaveOpen); + return 0; + }); + } + + public static void AddPicture(string path, params MiniExcelRustPicture[] pictures) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (pictures is null || pictures.Length == 0) + throw new ArgumentException("At least one picture is required.", nameof(pictures)); + EnsureAbiVersion(); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + foreach (var picture in pictures) + { + if (picture.ImageBytes is null || picture.ImageBytes.Length == 0) + throw new ArgumentException("Picture data is required.", nameof(pictures)); + if (picture.WidthPx <= 0 || picture.HeightPx <= 0) + throw new ArgumentOutOfRangeException(nameof(pictures), "Picture dimensions must be positive."); + using var nativeSheetName = new Utf8String(picture.SheetName); + using var nativeCellAddress = new Utf8String(picture.CellAddress); + var imageHandle = GCHandle.Alloc(picture.ImageBytes, GCHandleType.Pinned); + try + { + var result = NativeMethods.AddPicture( + nativePath.Pointer, + nativeSheetName.Pointer, + nativeCellAddress.Pointer, + imageHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)picture.ImageBytes.Length, + checked((uint)picture.WidthPx), + checked((uint)picture.HeightPx), + (byte)picture.Anchor, + picture.LocationX, + picture.LocationY); + if (result < 0) + throw CreateNativeException(result); + } + finally + { + imageHandle.Free(); + } + } + } + + public static void AddPicture( + Stream stream, + bool leaveOpen = false, + params MiniExcelRustPicture[] pictures) + { + ValidateReadableStream(stream); + ValidateWritableStream(stream); + if (!stream.CanSeek) + throw new ArgumentException("The stream must be seekable for picture insertion.", nameof(stream)); + stream.Position = 0; + var temporaryPath = StageStream(stream); + try + { + AddPicture(temporaryPath, pictures); + CopyFileToStream(temporaryPath, stream); + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + private static IEnumerable> QueryStreamIterator( + Stream stream, + bool useHeaderRow, + string? sheetName, + string startCell, + string? endCell, + MiniExcelRustReadOptions? configuration, + bool leaveOpen) + { + string? temporaryPath = null; + try + { + temporaryPath = StageStream(stream); + foreach (var row in QueryIterator(temporaryPath, useHeaderRow, sheetName, startCell, endCell, configuration)) + yield return row; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + private static IEnumerable> QueryTableStreamIterator( + Stream stream, + string? sheetName, + string tableName, + bool leaveOpen) + { + string? temporaryPath = null; + try + { + temporaryPath = StageStream(stream); + foreach (var row in QueryTableIterator(temporaryPath, sheetName, tableName)) + yield return row; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + private static IEnumerable> QueryTableIterator( + string path, + string? sheetName, + string tableName) + { + EnsureAbiVersion(); + + using var nativePath = new Utf8String(path); + using var nativeSheetName = new Utf8String(sheetName); + using var nativeTableName = new Utf8String(tableName); + var result = NativeMethods.QueryTableOpen( + nativePath.Pointer, + nativeSheetName.Pointer, + nativeTableName.Pointer, + out var rawHandle); + if (result < 0) + throw CreateNativeException(result); + + foreach (var row in ReadRows(rawHandle)) + yield return row; + } + + private static IEnumerable> QueryCsvStreamIterator( + Stream stream, + bool useHeaderRow, + MiniExcelRustCsvReadOptions? configuration, + bool leaveOpen) + { + string? temporaryPath = null; + try + { + temporaryPath = StageStream(stream); + foreach (var row in QueryCsvIterator(temporaryPath, useHeaderRow, configuration)) + yield return row; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + private static IEnumerable> QueryCsvIterator( + string path, + bool useHeaderRow, + MiniExcelRustCsvReadOptions? configuration) + { + EnsureAbiVersion(); + configuration ??= new MiniExcelRustCsvReadOptions(); + + using var nativePath = new Utf8String(path); + var result = NativeMethods.QueryCsvOpen( + nativePath.Pointer, + useHeaderRow ? (byte)1 : (byte)0, + (byte)configuration.Delimiter, + (byte)configuration.Encoding, + configuration.ReadEmptyStringAsNull ? (byte)1 : (byte)0, + configuration.TrimColumnNames ? (byte)1 : (byte)0, + out var rawHandle); + if (result < 0) + throw CreateNativeException(result); + + foreach (var row in ReadRows(rawHandle)) + yield return row; + } + + private static IEnumerable> QueryIterator( + string path, + bool useHeaderRow, + string? sheetName, + string startCell, + string? endCell, + MiniExcelRustReadOptions? configuration) + { + EnsureAbiVersion(); + + using var nativePath = new Utf8String(path); + using var nativeSheetName = new Utf8String(sheetName); + using var nativeStartCell = new Utf8String(startCell); + using var nativeEndCell = new Utf8String(endCell); + using var nativeCachePath = new Utf8String(configuration?.SharedStringCachePath); + int result; + IntPtr rawHandle; + if (configuration is not null) + { + result = NativeMethods.QueryOptionsOpen( + nativePath.Pointer, + useHeaderRow ? (byte)1 : (byte)0, + nativeSheetName.Pointer, + nativeStartCell.Pointer, + nativeEndCell.Pointer, + configuration.IgnoreEmptyRows ? (byte)1 : (byte)0, + configuration.FillMergedCells ? (byte)1 : (byte)0, + configuration.TrimColumnNames ? (byte)1 : (byte)0, + configuration.EnableSharedStringCache ? (byte)1 : (byte)0, + configuration.SharedStringCacheSize, + nativeCachePath.Pointer, + out rawHandle); + } + else if (endCell is not null) + { + result = NativeMethods.QueryRangeOpen( + nativePath.Pointer, + useHeaderRow ? (byte)1 : (byte)0, + nativeSheetName.Pointer, + nativeStartCell.Pointer, + nativeEndCell.Pointer, + out rawHandle); + } + else + { + result = NativeMethods.QueryOpen( + nativePath.Pointer, + useHeaderRow ? (byte)1 : (byte)0, + nativeSheetName.Pointer, + nativeStartCell.Pointer, + out rawHandle); + } + if (result < 0) + throw CreateNativeException(result); + + foreach (var row in ReadRows(rawHandle)) + yield return row; + } + + private static IEnumerable> ReadRows(IntPtr rawHandle) + { + using var handle = new NativeQueryHandle(rawHandle); + while (true) + { + var result = NativeMethods.QueryNextBatch(handle, BatchSize, out var data, out var length); + if (result == 0) + yield break; + if (result < 0) + throw CreateNativeException(result); + + var byteLength = checked((int)length.ToUInt64()); + var frame = new byte[byteLength]; + Marshal.Copy(data, frame, 0, byteLength); + foreach (var row in DecodeBatch(frame)) + yield return row; + } + } + + private static IEnumerable> DecodeBatch(byte[] frame) + { + var reader = new FrameReader(frame); + var rowCount = reader.ReadLength(); + for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) + { + var cellCount = reader.ReadLength(); + IDictionary row = new Dictionary(cellCount, StringComparer.Ordinal); + for (var cellIndex = 0; cellIndex < cellCount; cellIndex++) + row.Add(reader.ReadString(), reader.ReadValue()); + yield return row; + } + + reader.EnsureComplete(); + } + + private static List DecodeStrings(byte[] frame) + { + var reader = new FrameReader(frame); + var count = reader.ReadLength(); + var values = new List(count); + for (var index = 0; index < count; index++) + values.Add(reader.ReadString()); + reader.EnsureComplete(); + return values; + } + + private static List DecodeRanges(byte[] frame) + { + var reader = new FrameReader(frame); + var count = reader.ReadLength(); + var ranges = new List(count); + for (var index = 0; index < count; index++) + { + var startCell = reader.ReadString(); + var endCell = reader.ReadString(); + ranges.Add(new MiniExcelRustRange( + startCell.Length == 0 ? null : startCell, + endCell.Length == 0 ? null : endCell)); + } + reader.EnsureComplete(); + return ranges; + } + + private static List DecodeSheetInfo(byte[] frame) + { + var reader = new FrameReader(frame); + var count = reader.ReadLength(); + var sheets = new List(count); + for (var index = 0; index < count; index++) + { + sheets.Add(new MiniExcelRustSheetInfo( + reader.ReadUInt32(), + reader.ReadUInt32(), + reader.ReadString(), + (MiniExcelRustSheetType)reader.ReadByte(), + (MiniExcelRustSheetState)reader.ReadByte(), + reader.ReadByte() != 0)); + } + reader.EnsureComplete(); + return sheets; + } + + private static MiniExcelRustCommentResult DecodeComments(byte[] frame) + { + var reader = new FrameReader(frame); + var sheetName = reader.ReadString(); + var commentCount = reader.ReadLength(); + var comments = new List(commentCount); + for (var index = 0; index < commentCount; index++) + { + var id = Guid.Parse(reader.ReadString()); + var referenceCell = reader.ReadString(); + var author = ReadCommentAuthor(reader); + var createdAt = ReadCommentTimestamp(reader); + var resolved = reader.ReadByte() != 0; + var text = reader.ReadString(); + var replyCount = reader.ReadLength(); + var replies = new List(replyCount); + for (var replyIndex = 0; replyIndex < replyCount; replyIndex++) + { + replies.Add(new MiniExcelRustThreadedCommentReply( + Guid.Parse(reader.ReadString()), + Guid.Parse(reader.ReadString()), + ReadCommentAuthor(reader), + ReadCommentTimestamp(reader), + reader.ReadString())); + } + comments.Add(new MiniExcelRustThreadedComment( + id, + referenceCell, + author, + createdAt, + resolved, + text, + replies)); + } + + var noteCount = reader.ReadLength(); + var notes = new List(noteCount); + for (var index = 0; index < noteCount; index++) + { + var id = reader.ReadOptionalString(); + notes.Add(new MiniExcelRustNoteComment( + id is null ? null : Guid.Parse(id), + reader.ReadString(), + reader.ReadOptionalString() ?? string.Empty, + reader.ReadString())); + } + reader.EnsureComplete(); + return new MiniExcelRustCommentResult(sheetName, comments, notes); + } + + private static MiniExcelRustCommentAuthor? ReadCommentAuthor(FrameReader reader) + { + if (reader.ReadByte() == 0) + return null; + return new MiniExcelRustCommentAuthor( + Guid.Parse(reader.ReadString()), + reader.ReadString(), + reader.ReadOptionalString()); + } + + private static DateTime? ReadCommentTimestamp(FrameReader reader) + { + var value = reader.ReadOptionalString(); + return value is null + ? null + : DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + } + + private static DataTable CreateDataTable( + IReadOnlyList columns, + IEnumerable> rows) + { + var table = new DataTable(); + foreach (var column in columns) + table.Columns.Add(column, typeof(object)); + + foreach (var row in rows) + { + var values = new object?[columns.Count]; + for (var index = 0; index < columns.Count; index++) + values[index] = row.TryGetValue(columns[index], out var value) ? value ?? DBNull.Value : DBNull.Value; + table.Rows.Add(values); + } + + return table; + } + + private static async IAsyncEnumerable ToAsyncEnumerable( + IEnumerable values, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + foreach (var value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return value; + await Task.Yield(); + } + } + + private static byte[] EncodeRows(IEnumerable> rows) + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); + WriteRows(writer, rows); + writer.Flush(); + return stream.ToArray(); + } + + private static void ReportProgress( + IProgress? progress, + IEnumerable> rows) + { + if (progress is null) + return; + foreach (var row in rows) + { + foreach (var _ in row) + progress.Report(1); + } + } + + private static byte[] EncodeMapping(IReadOnlyDictionary mapping) + { + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); + writer.Write(checked((uint)mapping.Count)); + foreach (var cell in mapping) + { + if (string.IsNullOrWhiteSpace(cell.Key) || string.IsNullOrWhiteSpace(cell.Value)) + throw new ArgumentException("Mapping field names and cell addresses are required.", nameof(mapping)); + WriteFrameString(writer, cell.Key); + WriteFrameString(writer, cell.Value); + } + writer.Flush(); + return stream.ToArray(); + } + + private static byte[] EncodeSheets( + IEnumerable>>> sheets) + { + var materializedSheets = sheets.ToList(); + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream, Encoding.UTF8, leaveOpen: true); + writer.Write(checked((uint)materializedSheets.Count)); + foreach (var sheet in materializedSheets) + { + if (string.IsNullOrWhiteSpace(sheet.Key)) + throw new ArgumentException("Every sheet must have a name.", nameof(sheets)); + WriteFrameString(writer, sheet.Key); + WriteRows(writer, sheet.Value); + } + writer.Flush(); + return stream.ToArray(); + } + + private static void WriteRows( + BinaryWriter writer, + IEnumerable> rows) + { + var materializedRows = rows.ToList(); + writer.Write(checked((uint)materializedRows.Count)); + foreach (var row in materializedRows) + { + writer.Write(checked((uint)row.Count)); + foreach (var cell in row) + { + WriteFrameString(writer, cell.Key); + WriteFrameValue(writer, cell.Value); + } + } + } + + private static int WriteCsv( + string path, + IEnumerable> rows, + MiniExcelRustCsvWriteOptions? configuration, + bool append) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (rows is null) + throw new ArgumentNullException(nameof(rows)); + configuration ??= new MiniExcelRustCsvWriteOptions(); + if (configuration.Delimiter == '\0' || configuration.Delimiter > 0x7f) + throw new ArgumentException("The CSV delimiter must be a single-byte ASCII character.", nameof(configuration)); + + EnsureAbiVersion(); + var frame = EncodeRows(rows); + using var nativePath = new Utf8String(Path.GetFullPath(path)); + var frameHandle = GCHandle.Alloc(frame, GCHandleType.Pinned); + try + { + var result = append + ? NativeMethods.AppendCsv( + nativePath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + (byte)configuration.Delimiter, + (byte)configuration.Encoding, + configuration.WriteBom ? (byte)1 : (byte)0, + configuration.PrintHeader ? (byte)1 : (byte)0, + out var rowCount) + : NativeMethods.SaveCsv( + nativePath.Pointer, + frameHandle.AddrOfPinnedObject(), + (UIntPtr)(uint)frame.Length, + (byte)configuration.Delimiter, + (byte)configuration.Encoding, + configuration.WriteBom ? (byte)1 : (byte)0, + configuration.PrintHeader ? (byte)1 : (byte)0, + configuration.OverwriteFile ? (byte)1 : (byte)0, + out rowCount); + if (result < 0) + throw CreateNativeException(result); + return checked((int)rowCount); + } + finally + { + frameHandle.Free(); + } + } + + private static void WriteFrameString(BinaryWriter writer, string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + writer.Write(checked((uint)bytes.Length)); + writer.Write(bytes); + } + + private static void WriteFrameValue(BinaryWriter writer, object? value) + { + switch (value) + { + case null: + case DBNull: + writer.Write((byte)0); + break; + case bool boolean: + writer.Write((byte)1); + writer.Write((byte)(boolean ? 1 : 0)); + break; + case byte or sbyte or short or ushort or int or uint or long: + writer.Write((byte)2); + writer.Write(Convert.ToInt64(value, CultureInfo.InvariantCulture)); + break; + case ulong unsigned when unsigned <= long.MaxValue: + writer.Write((byte)2); + writer.Write((long)unsigned); + break; + case float or double or decimal: + writer.Write((byte)3); + writer.Write(Convert.ToDouble(value, CultureInfo.InvariantCulture)); + break; + case string text: + writer.Write((byte)4); + WriteFrameString(writer, text); + break; +#if NET8_0_OR_GREATER + case DateOnly date: + writer.Write((byte)5); + WriteFrameString(writer, date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); + break; + case TimeOnly time: + writer.Write((byte)6); + WriteFrameString(writer, time.ToString("HH:mm:ss.fffffff", CultureInfo.InvariantCulture)); + break; +#endif + case DateTime dateTime: + writer.Write((byte)7); + WriteFrameString(writer, dateTime.ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff", CultureInfo.InvariantCulture)); + break; + case TimeSpan duration: + writer.Write((byte)8); + writer.Write(checked((long)duration.TotalMilliseconds)); + break; + default: + throw new NotSupportedException($"Values of type {value.GetType().FullName} are not supported by SaveAs yet."); + } + } + + private static TResult UseStagedStream( + Stream stream, + bool leaveOpen, + Func operation) + { + ValidateReadableStream(stream); + string? temporaryPath = null; + try + { + temporaryPath = StageStream(stream); + return operation(temporaryPath); + } + finally + { + if (!leaveOpen) + stream.Dispose(); + DeleteTemporaryFile(temporaryPath); + } + } + + private static string StageStream(Stream stream) + { + var path = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-{Guid.NewGuid():N}.xlsx"); + try + { + using var output = File.Create(path); + stream.CopyTo(output); + return path; + } + catch + { + DeleteTemporaryFile(path); + throw; + } + } + + private static void ValidateReadableStream(Stream stream) + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + if (!stream.CanRead) + throw new ArgumentException("The stream must be readable.", nameof(stream)); + } + + private static void ValidateWritableStream(Stream stream) + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + if (!stream.CanWrite) + throw new ArgumentException("The stream must be writable.", nameof(stream)); + } + + private static void CopyFileToStream(string path, Stream destination) + { + if (destination.CanSeek) + { + destination.Position = 0; + destination.SetLength(0); + } + using var input = File.OpenRead(path); + input.CopyTo(destination); + } + + private static void ValidateCsvConfiguration(MiniExcelRustCsvReadOptions? configuration) + { + if (configuration is not null && (configuration.Delimiter == '\0' || configuration.Delimiter > 0x7f)) + throw new ArgumentException("The CSV delimiter must be a single-byte ASCII character.", nameof(configuration)); + } + + private static void ValidatePathAndSheet(string path, string sheetName) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (string.IsNullOrWhiteSpace(sheetName)) + throw new ArgumentException("The sheet name is required.", nameof(sheetName)); + } + + private static string ToCellReference(int row, int column) + { + if (row is < 1 or > 1_048_576) + throw new ArgumentOutOfRangeException(nameof(row)); + if (column is < 1 or > 16_384) + throw new ArgumentOutOfRangeException(nameof(column)); + var letters = string.Empty; + while (column > 0) + { + column--; + letters = (char)('A' + column % 26) + letters; + column /= 26; + } + return letters + row.ToString(CultureInfo.InvariantCulture); + } + + private static void DeleteTemporaryFile(string? path) + { + if (path is not null && File.Exists(path)) + File.Delete(path); + } + + private static void EnsureAbiVersion() + { + var version = NativeMethods.GetAbiVersion(); + if (version != 1) + throw new NotSupportedException($"MiniExcel Rust ABI version {version} is not supported."); + } + + private static Exception CreateNativeException(int result) + { + var data = NativeMethods.GetLastError(out var length); + var byteLength = checked((int)length.ToUInt64()); + if (data == IntPtr.Zero || byteLength == 0) + return new InvalidOperationException($"MiniExcel Rust query failed with native error {result}."); + + var bytes = new byte[byteLength]; + Marshal.Copy(data, bytes, 0, byteLength); + return new InvalidOperationException(Encoding.UTF8.GetString(bytes)); + } + + private sealed class FrameReader(byte[] frame) + { + private int _offset; + + public int ReadLength() + { + var value = ReadUInt32(); + if (value > int.MaxValue) + throw new InvalidDataException("The native MiniExcel frame contains an unsupported length."); + return (int)value; + } + + public string ReadString() + { + var length = ReadLength(); + EnsureAvailable(length); + var value = Encoding.UTF8.GetString(frame, _offset, length); + _offset += length; + return value; + } + + public string? ReadOptionalString() + { + return ReadByte() == 0 ? null : ReadString(); + } + + public object? ReadValue() + { + EnsureAvailable(1); + return frame[_offset++] switch + { + 0 => null, + 1 => ReadBoolean(), + 2 => Convert.ToDouble(ReadInt64(), CultureInfo.InvariantCulture), + 3 => BitConverter.Int64BitsToDouble(ReadInt64()), + 4 => ReadString(), + 5 => DateTime.ParseExact(ReadString(), "yyyy-MM-dd", CultureInfo.InvariantCulture), + 6 => TimeSpan.Parse(ReadString(), CultureInfo.InvariantCulture), + 7 => DateTime.Parse(ReadString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), + 8 => TimeSpan.FromMilliseconds(ReadInt64()), + 9 => ReadString(), + var tag => throw new InvalidDataException($"The native MiniExcel frame contains unknown value tag {tag}.") + }; + } + + public void EnsureComplete() + { + if (_offset != frame.Length) + throw new InvalidDataException("The native MiniExcel frame contains trailing data."); + } + + private bool ReadBoolean() + { + EnsureAvailable(1); + return frame[_offset++] != 0; + } + + public byte ReadByte() + { + EnsureAvailable(1); + return frame[_offset++]; + } + + public uint ReadUInt32() + { + EnsureAvailable(sizeof(uint)); + var value = (uint)(frame[_offset] + | frame[_offset + 1] << 8 + | frame[_offset + 2] << 16 + | frame[_offset + 3] << 24); + _offset += sizeof(uint); + return value; + } + + private long ReadInt64() + { + EnsureAvailable(sizeof(long)); + ulong value = 0; + for (var index = 0; index < sizeof(long); index++) + value |= (ulong)frame[_offset + index] << (index * 8); + _offset += sizeof(long); + return unchecked((long)value); + } + + private void EnsureAvailable(int length) + { + if (length < 0 || _offset > frame.Length - length) + throw new InvalidDataException("The native MiniExcel frame is truncated."); + } + } + + private sealed class Utf8String : IDisposable + { + public Utf8String(string? value) + { + if (value is null) + return; + + var bytes = Encoding.UTF8.GetBytes(value); + Pointer = Marshal.AllocHGlobal(bytes.Length + 1); + Marshal.Copy(bytes, 0, Pointer, bytes.Length); + Marshal.WriteByte(Pointer, bytes.Length, 0); + } + + public IntPtr Pointer { get; private set; } + + public void Dispose() + { + if (Pointer == IntPtr.Zero) + return; + + Marshal.FreeHGlobal(Pointer); + Pointer = IntPtr.Zero; + } + } + + private sealed class NativeQueryHandle : SafeHandleZeroOrMinusOneIsInvalid + { + public NativeQueryHandle() : base(true) { } + + public NativeQueryHandle(IntPtr value) : this() + { + SetHandle(value); + } + + protected override bool ReleaseHandle() + { + NativeMethods.QueryClose(handle); + return true; + } + } + + private sealed class NativeBufferHandle : SafeHandleZeroOrMinusOneIsInvalid + { + public NativeBufferHandle() : base(true) { } + + public NativeBufferHandle(IntPtr value) : this() + { + SetHandle(value); + } + + protected override bool ReleaseHandle() + { + NativeMethods.BufferClose(handle); + return true; + } + } + + private sealed class NativeCancellationHandle : SafeHandleZeroOrMinusOneIsInvalid + { + private NativeCancellationHandle() : base(true) { } + + public static NativeCancellationHandle Create() + { + var result = NativeMethods.CreateCancellation(out var rawHandle); + if (result < 0) + throw CreateNativeException(result); + var handle = new NativeCancellationHandle(); + handle.SetHandle(rawHandle); + return handle; + } + + protected override bool ReleaseHandle() + { + NativeMethods.CloseCancellation(handle); + return true; + } + } + + private static class NativeMethods + { + private const string LibraryName = "miniexcel_ffi"; + + [DllImport(LibraryName, EntryPoint = "miniexcel_abi_version", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern uint GetAbiVersion(); + + [DllImport(LibraryName, EntryPoint = "miniexcel_query_open", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int QueryOpen( + IntPtr path, + byte useHeaderRow, + IntPtr sheetName, + 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, + uint maxRows, + out IntPtr data, + out UIntPtr length); + + [DllImport(LibraryName, EntryPoint = "miniexcel_query_close", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void QueryClose(IntPtr handle); + + [DllImport(LibraryName, EntryPoint = "miniexcel_get_sheet_names", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int GetSheetNames( + IntPtr path, + out IntPtr handle, + out IntPtr data, + out UIntPtr length); + + [DllImport(LibraryName, EntryPoint = "miniexcel_get_columns", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int GetColumns( + IntPtr path, + byte useHeaderRow, + IntPtr sheetName, + IntPtr startCell, + out IntPtr handle, + out IntPtr data, + out UIntPtr length); + + [DllImport(LibraryName, EntryPoint = "miniexcel_get_sheet_dimensions", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int GetSheetDimensions( + IntPtr path, + out IntPtr handle, + out IntPtr data, + out UIntPtr length); + + [DllImport(LibraryName, EntryPoint = "miniexcel_get_sheet_info", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int GetSheetInfo( + IntPtr path, + out IntPtr handle, + out IntPtr data, + out UIntPtr length); + + [DllImport(LibraryName, EntryPoint = "miniexcel_get_comments", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int GetComments( + IntPtr path, + IntPtr sheetName, + out IntPtr handle, + out IntPtr data, + out UIntPtr length); + + [DllImport(LibraryName, EntryPoint = "miniexcel_read_mapped", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int ReadMapped( + IntPtr path, + IntPtr sheetName, + IntPtr mappingData, + UIntPtr mappingLength, + out IntPtr handle, + out IntPtr data, + out UIntPtr length); + + [DllImport(LibraryName, EntryPoint = "miniexcel_save_as", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SaveAs( + IntPtr path, + IntPtr data, + UIntPtr dataLength, + byte printHeader, + IntPtr sheetName, + byte overwriteFile, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_save_as_sheets", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SaveAsSheets( + IntPtr path, + IntPtr data, + UIntPtr dataLength, + byte printHeader, + byte overwriteFile, + out IntPtr handle, + out IntPtr resultData, + out UIntPtr resultLength); + + [DllImport(LibraryName, EntryPoint = "miniexcel_save_as_configured", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SaveAsConfigured( + IntPtr path, + IntPtr data, + UIntPtr dataLength, + IntPtr optionsJson, + UIntPtr optionsLength, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_save_csv", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SaveCsv( + IntPtr path, + IntPtr data, + UIntPtr dataLength, + byte delimiter, + byte encoding, + byte writeBom, + byte printHeader, + byte overwriteFile, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_append_csv", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int AppendCsv( + IntPtr path, + IntPtr data, + UIntPtr dataLength, + byte delimiter, + byte encoding, + byte writeBom, + byte printHeader, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_rename_sheet", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int RenameSheet(IntPtr path, IntPtr sheetName, IntPtr newSheetName); + + [DllImport(LibraryName, EntryPoint = "miniexcel_reorder_sheet", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int ReorderSheet(IntPtr path, IntPtr sheetName, int newSheetIndex); + + [DllImport(LibraryName, EntryPoint = "miniexcel_set_sheet_visibility", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SetSheetVisibility(IntPtr path, IntPtr sheetName, byte visibility); + + [DllImport(LibraryName, EntryPoint = "miniexcel_insert_sheet", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int InsertSheet( + IntPtr path, + IntPtr data, + UIntPtr dataLength, + IntPtr sheetName, + byte printHeader, + byte replaceExisting, + byte removeSupportedRelationships, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_copy_and_add_sheet", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int CopyAndAddSheet( + IntPtr sourcePath, + IntPtr destinationPath, + IntPtr data, + UIntPtr dataLength, + IntPtr sheetName, + byte printHeader, + byte replaceExisting, + byte removeSupportedRelationships, + byte overwriteDestination, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_fill_template", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int FillTemplate( + IntPtr destinationPath, + IntPtr templatePath, + IntPtr jsonData, + UIntPtr jsonLength, + byte overwriteFile, + byte ignoreMissingVariables); + + [DllImport(LibraryName, EntryPoint = "miniexcel_fill_mapped_template", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int FillMappedTemplate( + IntPtr destinationPath, + IntPtr templatePath, + IntPtr jsonData, + UIntPtr jsonLength, + byte overwriteFile); + + [DllImport(LibraryName, EntryPoint = "miniexcel_merge_same_cells", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int MergeSameCells( + IntPtr destinationPath, + IntPtr sourcePath, + byte overwriteFile); + + [DllImport(LibraryName, EntryPoint = "miniexcel_add_picture", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int AddPicture( + IntPtr path, + IntPtr sheetName, + IntPtr cellAddress, + IntPtr imageData, + UIntPtr imageLength, + uint widthPx, + uint heightPx, + byte anchorType, + int locationX, + int locationY); + + [DllImport(LibraryName, EntryPoint = "miniexcel_save_as_spooled_async", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SaveAsSpooledAsync( + IntPtr path, + IntPtr spoolPath, + IntPtr optionsJson, + UIntPtr optionsLength, + NativeCancellationHandle cancellation, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_save_csv_spooled_async", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int SaveCsvSpooledAsync( + IntPtr path, + IntPtr spoolPath, + IntPtr optionsJson, + UIntPtr optionsLength, + NativeCancellationHandle cancellation, + out uint rowCount); + + [DllImport(LibraryName, EntryPoint = "miniexcel_cancellation_create", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern int CreateCancellation(out IntPtr handle); + + [DllImport(LibraryName, EntryPoint = "miniexcel_cancellation_cancel", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void Cancel(NativeCancellationHandle handle); + + [DllImport(LibraryName, EntryPoint = "miniexcel_cancellation_close", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void CloseCancellation(IntPtr handle); + + [DllImport(LibraryName, EntryPoint = "miniexcel_buffer_close", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern void BufferClose(IntPtr handle); + + [DllImport(LibraryName, EntryPoint = "miniexcel_last_error", CallingConvention = CallingConvention.Cdecl, ExactSpelling = true)] + internal static extern IntPtr GetLastError(out UIntPtr length); + } +} \ No newline at end of file diff --git a/dotnet/src/MiniExcel.Rust/MiniExcelRustComments.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRustComments.cs new file mode 100644 index 0000000..8842fc5 --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/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/dotnet/src/MiniExcel.Rust/MiniExcelRustCsvReadOptions.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRustCsvReadOptions.cs new file mode 100644 index 0000000..bf4ab5b --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/MiniExcelRustCsvReadOptions.cs @@ -0,0 +1,37 @@ +namespace MiniExcelLibs; + +public enum MiniExcelRustCsvEncoding : byte +{ + Utf8, + Utf16Le, + Utf16Be, + Gbk, + Windows1252 +} + +/// +/// Configures Rust-backed CSV queries. +/// +public sealed class MiniExcelRustCsvReadOptions +{ + public static MiniExcelRustCsvReadOptions FromMiniExcel( + Csv.CsvConfiguration configuration) => + MiniExcelV1Adapters.ToReadOptions(configuration); + + public static implicit operator MiniExcelRustCsvReadOptions( + Csv.CsvConfiguration configuration) => + FromMiniExcel(configuration); + + public System.Globalization.CultureInfo Culture { get; set; } = System.Globalization.CultureInfo.InvariantCulture; + + public IDictionary DynamicColumns { get; } = + new Dictionary(StringComparer.Ordinal); + + public char Delimiter { get; set; } = ','; + + public MiniExcelRustCsvEncoding Encoding { get; set; } = MiniExcelRustCsvEncoding.Utf8; + + public bool ReadEmptyStringAsNull { get; set; } + + public bool TrimColumnNames { get; set; } +} \ No newline at end of file diff --git a/dotnet/src/MiniExcel.Rust/MiniExcelRustCsvWriteOptions.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRustCsvWriteOptions.cs new file mode 100644 index 0000000..868bf00 --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/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/dotnet/src/MiniExcel.Rust/MiniExcelRustDynamicColumn.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRustDynamicColumn.cs new file mode 100644 index 0000000..4f2512b --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/MiniExcelRustDynamicColumn.cs @@ -0,0 +1,11 @@ +namespace MiniExcelLibs; + +public sealed class MiniExcelRustDynamicColumn +{ + public string? Name { get; set; } + public int? Index { get; set; } + public string? Format { get; set; } + public bool Ignore { get; set; } + public Func? CustomFormatter { get; set; } + public bool IsFormula { get; set; } +} \ No newline at end of file diff --git a/dotnet/src/MiniExcel.Rust/MiniExcelRustFluentMapping.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRustFluentMapping.cs new file mode 100644 index 0000000..02ecbec --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/MiniExcelRustFluentMapping.cs @@ -0,0 +1,731 @@ +using System.Collections; +using System.Globalization; +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; + +namespace MiniExcelLibs; + +public sealed class MiniExcelRustMapping +{ + private readonly List _nodes = new(); + + public string WorksheetName { get; private set; } = "Sheet1"; + + public MiniExcelRustPropertyMapping Property( + Expression> property) + { + if (property is null) + throw new ArgumentNullException(nameof(property)); + var node = new PropertyNode( + source => property.Compile()((T)source), + CreateSetter(property), + typeof(TProperty)); + _nodes.Add(node); + return new MiniExcelRustPropertyMapping(node); + } + + public MiniExcelRustCollectionMapping Collection( + Expression> collection) + where TCollection : IEnumerable + { + if (collection is null) + throw new ArgumentNullException(nameof(collection)); + var node = new CollectionNode( + source => propertyValue(collection.Compile()((T)source)), + CreateSetter(collection), + typeof(TCollection), + CollectionItemType(typeof(TCollection))); + _nodes.Add(node); + return new MiniExcelRustCollectionMapping(node); + + static IEnumerable propertyValue(TCollection value) => value; + } + + public MiniExcelRustMapping ToWorksheet(string worksheetName) + { + if (string.IsNullOrWhiteSpace(worksheetName) || worksheetName.Length > 31) + throw new ArgumentException("The worksheet name must contain 1 to 31 characters.", nameof(worksheetName)); + WorksheetName = worksheetName; + return this; + } + + internal int MaximumRow => _nodes.Count == 0 ? 1 : _nodes.Max(node => node.MaximumRow); + internal int MinimumRow => _nodes.Count == 0 ? 1 : _nodes.Min(node => node.MinimumRow); + + internal int Write(object source, MappedGrid grid, int rowOffset) + { + var maximumRow = rowOffset; + foreach (var node in _nodes) + maximumRow = Math.Max(maximumRow, node.Write(source, grid, rowOffset)); + return maximumRow; + } + + internal void Read(object destination, IReadOnlyList> rows, int rowOffset, int endRow) + { + foreach (var node in _nodes) + node.Read(destination, rows, rowOffset, endRow); + } + + internal bool HasAnchorData(IReadOnlyList> rows, int rowOffset) + { + var directAnchors = _nodes + .OfType() + .Where(node => node.MinimumRow == MinimumRow) + .Cast() + .ToArray(); + var anchors = directAnchors.Length > 0 + ? directAnchors + : _nodes.Where(node => node.MinimumRow == MinimumRow); + return anchors.Any(node => node.HasData(rows, rowOffset)); + } + + private static Action? CreateSetter(Expression> expression) + { + var member = expression.Body as MemberExpression; + if (member is null && expression.Body is UnaryExpression unary) + member = unary.Operand as MemberExpression; + return member?.Member switch + { + PropertyInfo property when property.CanWrite => (target, value) => property.SetValue(target, ConvertValue(value, property.PropertyType)), + FieldInfo field when !field.IsInitOnly => (target, value) => field.SetValue(target, ConvertValue(value, field.FieldType)), + _ => null + }; + } + + private static Type CollectionItemType(Type collectionType) + { + if (collectionType.IsArray) + return collectionType.GetElementType()!; + return collectionType + .GetInterfaces() + .Concat(new[] { collectionType }) + .FirstOrDefault(type => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))? + .GetGenericArguments()[0] ?? typeof(object); + } + + internal static object? ConvertValue(object? value, Type targetType) + { + if (value is null || value is DBNull) + return targetType.IsValueType && Nullable.GetUnderlyingType(targetType) is null + ? Activator.CreateInstance(targetType) + : null; + var effectiveType = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (effectiveType.IsInstanceOfType(value)) + return value; + if (effectiveType.IsEnum) + return value is string text + ? Enum.Parse(effectiveType, text, true) + : Enum.ToObject(effectiveType, Convert.ToInt64(value, CultureInfo.InvariantCulture)); + if (effectiveType == typeof(Guid)) + return Guid.Parse(Convert.ToString(value, CultureInfo.InvariantCulture)!); + if (effectiveType == typeof(Uri)) + return new Uri(Convert.ToString(value, CultureInfo.InvariantCulture)!, UriKind.RelativeOrAbsolute); + if (effectiveType == typeof(TimeSpan)) + return value is TimeSpan span ? span : TimeSpan.Parse(Convert.ToString(value, CultureInfo.InvariantCulture)!, CultureInfo.InvariantCulture); + if (effectiveType == typeof(DateTimeOffset)) + return value is DateTimeOffset offset ? offset : new DateTimeOffset(Convert.ToDateTime(value, CultureInfo.InvariantCulture)); + return Convert.ChangeType(value, effectiveType, CultureInfo.InvariantCulture); + } + + internal interface IMappedNode + { + int MinimumRow { get; } + int MaximumRow { get; } + int Write(object source, MappedGrid grid, int rowOffset); + void Read(object destination, IReadOnlyList> rows, int rowOffset, int endRow); + bool HasData(IReadOnlyList> rows, int rowOffset); + } + + internal sealed class PropertyNode( + Func getter, + Action? setter, + Type propertyType) : IMappedNode + { + public int Row { get; set; } + public int Column { get; set; } + public string? Format { get; set; } + public string? Formula { get; set; } + public int MinimumRow => Row; + public int MaximumRow => Row; + + public int Write(object source, MappedGrid grid, int rowOffset) + { + if (Row == 0) + throw new InvalidOperationException("A property mapping requires ToCell()."); + grid.Set(Row + rowOffset, Column, Formula ?? getter(source), Format, Formula is not null); + return Row + rowOffset; + } + + public void Read(object destination, IReadOnlyList> rows, int rowOffset, int endRow) + { + if (Row == 0) + throw new InvalidOperationException("A property mapping requires ToCell()."); + if (setter is null) + throw new InvalidOperationException("A mapped property must be writable when reading."); + if (MappedGrid.TryGet(rows, Row + rowOffset, Column, out var value)) + setter(destination, ConvertValue(value, propertyType)); + } + + public bool HasData(IReadOnlyList> rows, int rowOffset) => + Row != 0 && MappedGrid.TryGet(rows, Row + rowOffset, Column, out _); + } + + internal sealed class CollectionNode( + Func getter, + Action? setter, + Type collectionType, + Type itemType) : IMappedNode + { + public int Row { get; set; } + public int Column { get; set; } + public int Spacing { get; set; } + public IItemPlan? ItemPlan { get; set; } + public int MinimumRow => Row; + public int MaximumRow => ItemPlan?.MaximumRow ?? Math.Max(1, Row); + + public int Write(object source, MappedGrid grid, int rowOffset) + { + if (Row == 0) + throw new InvalidOperationException("A collection mapping requires StartAt()."); + var maximumRow = Row + rowOffset; + var itemOffset = rowOffset; + foreach (var item in getter(source)) + { + if (ItemPlan is null) + { + grid.Set(Row + itemOffset, Column, item, null, false); + maximumRow = Row + itemOffset; + itemOffset += Spacing + 1; + } + else if (item is not null) + { + maximumRow = Math.Max(maximumRow, ItemPlan.Write(item, grid, itemOffset)); + itemOffset += Math.Max(1, maximumRow - (Row + itemOffset) + 1) + Spacing; + } + } + return maximumRow; + } + + public void Read(object destination, IReadOnlyList> rows, int rowOffset, int endRow) + { + if (Row == 0) + throw new InvalidOperationException("A collection mapping requires StartAt()."); + if (setter is null) + throw new InvalidOperationException("A mapped collection must be writable when reading."); + var values = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(itemType))!; + if (ItemPlan is null) + { + for (var row = Row + rowOffset; row <= endRow; row += Spacing + 1) + { + if (!MappedGrid.TryGet(rows, row, Column, out var value)) + break; + values.Add(ConvertValue(value, itemType)); + } + } + else + { + var starts = new List(); + for (var row = Row + rowOffset; row <= endRow; row++) + { + var itemOffset = row - ItemPlan.MinimumRow; + if (ItemPlan.HasAnchorData(rows, itemOffset)) + starts.Add(row); + } + for (var index = 0; index < starts.Count; index++) + { + var itemOffset = starts[index] - ItemPlan.MinimumRow; + var itemEnd = index + 1 < starts.Count ? starts[index + 1] - 1 : endRow; + values.Add(ItemPlan.Read(rows, itemOffset, itemEnd)); + } + } + setter(destination, AdaptCollection(values, collectionType, itemType)); + } + + public bool HasData(IReadOnlyList> rows, int rowOffset) => + Row != 0 && (ItemPlan?.HasAnchorData(rows, rowOffset + Row - ItemPlan.MinimumRow) + ?? MappedGrid.TryGet(rows, Row + rowOffset, Column, out _)); + + private static object AdaptCollection(IList values, Type targetType, Type elementType) + { + if (targetType.IsArray) + { + var array = Array.CreateInstance(elementType, values.Count); + values.CopyTo(array, 0); + return array; + } + if (targetType.IsInstanceOfType(values)) + return values; + if (Activator.CreateInstance(targetType) is IList target) + { + foreach (var value in values) + target.Add(value); + return target; + } + throw new InvalidOperationException($"Mapped collection type '{targetType}' cannot be populated."); + } + } + + internal interface IItemPlan + { + int MinimumRow { get; } + int MaximumRow { get; } + int Write(object source, MappedGrid grid, int rowOffset); + object Read(IReadOnlyList> rows, int rowOffset, int endRow); + bool HasAnchorData(IReadOnlyList> rows, int rowOffset); + } + + internal sealed class ItemPlan(MiniExcelRustMapping mapping) : IItemPlan + { + public int MinimumRow => mapping.MinimumRow; + public int MaximumRow => mapping.MaximumRow; + public int Write(object source, MappedGrid grid, int rowOffset) => mapping.Write(source, grid, rowOffset); + public object Read(IReadOnlyList> rows, int rowOffset, int endRow) + { + var item = Activator.CreateInstance()!; + mapping.Read(item!, rows, rowOffset, endRow); + return item!; + } + public bool HasAnchorData(IReadOnlyList> rows, int rowOffset) => + mapping.HasAnchorData(rows, rowOffset); + } +} + +public sealed class MiniExcelRustPropertyMapping +{ + private readonly MiniExcelRustMapping.PropertyNode _node; + + internal MiniExcelRustPropertyMapping(MiniExcelRustMapping.PropertyNode node) + { + _node = node; + } + + public MiniExcelRustPropertyMapping ToCell(string cellAddress) + { + (_node.Row, _node.Column) = MappedGrid.ParseCell(cellAddress); + return this; + } + + public MiniExcelRustPropertyMapping WithFormat(string format) + { + _node.Format = format; + return this; + } + + public MiniExcelRustPropertyMapping WithFormula(string formula) + { + _node.Formula = formula; + return this; + } +} + +public sealed class MiniExcelRustCollectionMapping + where TCollection : IEnumerable +{ + private readonly MiniExcelRustMapping.CollectionNode _node; + + internal MiniExcelRustCollectionMapping(MiniExcelRustMapping.CollectionNode node) + { + _node = node; + } + + public MiniExcelRustCollectionMapping StartAt(string cellAddress) + { + (_node.Row, _node.Column) = MappedGrid.ParseCell(cellAddress); + return this; + } + + public MiniExcelRustCollectionMapping WithSpacing(int spacing) + { + if (spacing < 0) + throw new ArgumentOutOfRangeException(nameof(spacing)); + _node.Spacing = spacing; + return this; + } + + public MiniExcelRustCollectionMapping WithItemMapping( + Action> configure) + { + if (configure is null) + throw new ArgumentNullException(nameof(configure)); + var mapping = new MiniExcelRustMapping(); + configure(mapping); + _node.ItemPlan = new MiniExcelRustMapping.ItemPlan(mapping); + return this; + } +} + +internal sealed class MappedGrid +{ + private readonly SortedDictionary<(int Row, int Column), object?> _values = new(); + private readonly Dictionary _formats = new(); + private readonly HashSet<(int Row, int Column)> _formulaCells = new(); + + public void Set(int row, int column, object? value, string? format, bool formula) + { + _values[(row, column)] = value; + if (format is not null) + _formats[column] = format; + if (formula) + _formulaCells.Add((row, column)); + } + + public int Save(string path, string sheetName, bool overwriteFile) + { + if (_values.Count == 0) + throw new InvalidOperationException("The mapping did not produce any cells."); + var maxRow = _values.Keys.Max(cell => cell.Row); + var maxColumn = _values.Keys.Max(cell => cell.Column); + var schema = Enumerable.Range(1, maxColumn).Select(ColumnName).ToArray(); + var rows = new List>(maxRow); + for (var rowIndex = 1; rowIndex <= maxRow; rowIndex++) + { + IDictionary row = new Dictionary(StringComparer.Ordinal); + foreach (var cell in _values.Where(value => value.Key.Row == rowIndex)) + row[ColumnName(cell.Key.Column)] = cell.Value; + rows.Add(row); + } + var options = new MiniExcelRustWriteOptions + { + SheetName = sheetName, + PrintHeader = false, + OverwriteFile = overwriteFile + }; + foreach (var format in _formats) + options.ColumnFormats[ColumnName(format.Key)] = format.Value; + var count = MiniExcelRust.SaveAsWithSchema(path, schema, rows, options); + if (_formulaCells.Count > 0) + MiniExcelRust.FillMappedTemplateCore(path, path, CreateTemplatePayload(sheetName), true); + return count; + } + + public byte[] CreateTemplatePayload(string sheetName) + { + return JsonSerializer.SerializeToUtf8Bytes(new + { + sheetName, + cells = _values.Select(cell => new + { + address = $"{ColumnName(cell.Key.Column)}{cell.Key.Row}", + value = cell.Value, + formula = _formulaCells.Contains(cell.Key) + }) + }); + } + + internal static (int Row, int Column) ParseCell(string cellAddress) + { + if (string.IsNullOrWhiteSpace(cellAddress)) + throw new ArgumentException("A cell address is required.", nameof(cellAddress)); + var letters = cellAddress.TakeWhile(char.IsLetter).ToArray(); + var digits = cellAddress.SkipWhile(char.IsLetter).ToArray(); + if (letters.Length == 0 || digits.Length == 0 || !int.TryParse(new string(digits), out var row) || row < 1) + throw new ArgumentException($"Invalid cell address '{cellAddress}'.", nameof(cellAddress)); + var column = 0; + foreach (var letter in letters) + { + if (letter is not (>= 'A' and <= 'Z') and not (>= 'a' and <= 'z')) + throw new ArgumentException($"Invalid cell address '{cellAddress}'.", nameof(cellAddress)); + column = checked(column * 26 + char.ToUpperInvariant(letter) - 'A' + 1); + } + if (column > 16_384 || row > 1_048_576) + throw new ArgumentOutOfRangeException(nameof(cellAddress)); + return (row, column); + } + + internal static bool TryGet( + IReadOnlyList> rows, + int row, + int column, + out object? value) + { + value = null; + return row > 0 && row <= rows.Count && + rows[row - 1].TryGetValue(ColumnName(column), out value) && + value is not null && value is not DBNull && + (value is not string text || text.Length > 0); + } + + internal static string ColumnName(int column) + { + var name = string.Empty; + while (column > 0) + { + column--; + name = (char)('A' + column % 26) + name; + column /= 26; + } + return name; + } +} + +public static partial class MiniExcelRustMappingExtensions +{ + public static T ReadMapped( + string path, + MiniExcelRustMapping mapping) + where T : new() + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path is required.", nameof(path)); + if (mapping is null) + throw new ArgumentNullException(nameof(mapping)); + var rows = MiniExcelRust.Query(path, false, mapping.WorksheetName).ToList(); + var value = new T(); + mapping.Read(value!, rows, 0, rows.Count); + return value; + } + + public static T ReadMapped( + Stream stream, + MiniExcelRustMapping mapping, + bool leaveOpen = false) + where T : new() + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + if (mapping is null) + throw new ArgumentNullException(nameof(mapping)); + var rows = MiniExcelRust.Query(stream, false, mapping.WorksheetName, leaveOpen: leaveOpen).ToList(); + var value = new T(); + mapping.Read(value!, rows, 0, rows.Count); + return value; + } + + public static Task ReadMappedAsync( + string path, + MiniExcelRustMapping mapping, + CancellationToken cancellationToken = default) + where T : new() + { + return Task.Run(() => ReadMapped(path, mapping), cancellationToken); + } + + public static Task ReadMappedAsync( + Stream stream, + MiniExcelRustMapping mapping, + bool leaveOpen = false, + CancellationToken cancellationToken = default) + where T : new() + { + return Task.Run(() => ReadMapped(stream, mapping, leaveOpen), cancellationToken); + } + + public static int ExportMapped( + string path, + IEnumerable values, + MiniExcelRustMapping mapping, + bool overwriteFile = false) + { + if (values is null) + throw new ArgumentNullException(nameof(values)); + if (mapping is null) + throw new ArgumentNullException(nameof(mapping)); + var grid = BuildGrid(values, mapping); + return grid.Save(path, mapping.WorksheetName, overwriteFile); + } + + public static int ExportMapped( + Stream stream, + IEnumerable values, + MiniExcelRustMapping mapping, + bool leaveOpen = false) + { + if (stream is null) + throw new ArgumentNullException(nameof(stream)); + if (!stream.CanWrite) + throw new ArgumentException("The stream must be writable.", nameof(stream)); + var outputPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-mapped-export-{Guid.NewGuid():N}.xlsx"); + try + { + var count = ExportMapped(outputPath, values, mapping); + CopyToStream(outputPath, stream); + return count; + } + finally + { + if (!leaveOpen) + stream.Dispose(); + if (File.Exists(outputPath)) + File.Delete(outputPath); + } + } + + public static Task ExportMappedAsync( + string path, + IEnumerable values, + MiniExcelRustMapping mapping, + bool overwriteFile = false, + CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + var grid = BuildGrid(values, mapping, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return grid.Save(path, mapping.WorksheetName, overwriteFile); + }, cancellationToken); + } + + public static Task ExportMappedAsync( + Stream stream, + IEnumerable values, + MiniExcelRustMapping mapping, + bool leaveOpen = false, + CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + return ExportMapped(stream, values, mapping, leaveOpen); + }, cancellationToken); + } + + public static void FillMappedTemplate( + string destinationPath, + string templatePath, + IEnumerable values, + MiniExcelRustMapping mapping, + bool overwriteFile = false) + { + if (values is null) + throw new ArgumentNullException(nameof(values)); + if (mapping is null) + throw new ArgumentNullException(nameof(mapping)); + var grid = BuildGrid(values, mapping); + MiniExcelRust.FillMappedTemplateCore( + destinationPath, + templatePath, + grid.CreateTemplatePayload(mapping.WorksheetName), + overwriteFile); + } + + public static void FillMappedTemplate( + Stream outputStream, + Stream templateStream, + IEnumerable values, + MiniExcelRustMapping mapping, + bool leaveOpen = false, + bool leaveTemplateOpen = false) + { + if (outputStream is null) + throw new ArgumentNullException(nameof(outputStream)); + if (!outputStream.CanWrite) + throw new ArgumentException("The stream must be writable.", nameof(outputStream)); + if (templateStream is null) + throw new ArgumentNullException(nameof(templateStream)); + if (!templateStream.CanRead) + throw new ArgumentException("The stream must be readable.", nameof(templateStream)); + var templatePath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-mapped-template-{Guid.NewGuid():N}.xlsx"); + var outputPath = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-mapped-output-{Guid.NewGuid():N}.xlsx"); + try + { + using (var file = File.Create(templatePath)) + templateStream.CopyTo(file); + FillMappedTemplate(outputPath, templatePath, values, mapping); + if (outputStream.CanSeek) + { + outputStream.Position = 0; + outputStream.SetLength(0); + } + using var result = File.OpenRead(outputPath); + result.CopyTo(outputStream); + } + finally + { + if (!leaveOpen) + outputStream.Dispose(); + if (!leaveTemplateOpen) + templateStream.Dispose(); + if (File.Exists(templatePath)) + File.Delete(templatePath); + if (File.Exists(outputPath)) + File.Delete(outputPath); + } + } + + public static void FillMappedTemplate( + Stream outputStream, + byte[] templateBytes, + IEnumerable values, + MiniExcelRustMapping mapping, + bool leaveOpen = false) + { + if (templateBytes is null) + throw new ArgumentNullException(nameof(templateBytes)); + using var templateStream = new MemoryStream(templateBytes, writable: false); + FillMappedTemplate(outputStream, templateStream, values, mapping, leaveOpen, false); + } + + public static Task FillMappedTemplateAsync( + string destinationPath, + string templatePath, + IEnumerable values, + MiniExcelRustMapping mapping, + bool overwriteFile = false, + CancellationToken cancellationToken = default) + { + return Task.Run( + () => FillMappedTemplate(destinationPath, templatePath, values, mapping, overwriteFile), + cancellationToken); + } + + public static Task FillMappedTemplateAsync( + Stream outputStream, + Stream templateStream, + IEnumerable values, + MiniExcelRustMapping mapping, + bool leaveOpen = false, + bool leaveTemplateOpen = false, + CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + FillMappedTemplate(outputStream, templateStream, values, mapping, leaveOpen, leaveTemplateOpen); + }, cancellationToken); + } + + public static Task FillMappedTemplateAsync( + Stream outputStream, + byte[] templateBytes, + IEnumerable values, + MiniExcelRustMapping mapping, + bool leaveOpen = false, + CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + FillMappedTemplate(outputStream, templateBytes, values, mapping, leaveOpen); + }, cancellationToken); + } + + private static MappedGrid BuildGrid( + IEnumerable values, + MiniExcelRustMapping mapping, + CancellationToken cancellationToken = default) + { + var grid = new MappedGrid(); + var offset = 0; + foreach (var value in values) + { + cancellationToken.ThrowIfCancellationRequested(); + if (value is null) + throw new ArgumentException("Mapped values cannot contain null.", nameof(values)); + var maximumRow = mapping.Write(value, grid, offset); + offset = Math.Max(offset + mapping.MaximumRow, maximumRow); + } + return grid; + } + + private static void CopyToStream(string path, Stream stream) + { + if (stream.CanSeek) + { + stream.Position = 0; + stream.SetLength(0); + } + using var input = File.OpenRead(path); + input.CopyTo(stream); + } +} \ No newline at end of file diff --git a/dotnet/src/MiniExcel.Rust/MiniExcelRustInsertOptions.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRustInsertOptions.cs new file mode 100644 index 0000000..4a95961 --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/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/dotnet/src/MiniExcel.Rust/MiniExcelRustMapper.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRustMapper.cs new file mode 100644 index 0000000..16cabea --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/MiniExcelRustMapper.cs @@ -0,0 +1,421 @@ +using System.ComponentModel; +using System.Collections; +using System.Globalization; +using System.Reflection; +using System.Resources; + +namespace MiniExcelLibs; + +internal static class MiniExcelRustMapper +{ + public static IEnumerable> ToRows(IEnumerable values) + { + IReadOnlyList? mappings = null; + Type? mappedType = null; + foreach (var value in values) + { + if (value is null) + throw new ArgumentException("Typed export rows cannot contain null values.", nameof(values)); + if (value is IDictionary row) + { + yield return row; + continue; + } + var valueType = value.GetType(); + if (mappedType != valueType) + { + mappedType = valueType; + mappings = CreateMappings(valueType, true, CultureInfo.InvariantCulture, null) + .OrderBy(mapping => mapping.Index ?? int.MaxValue) + .ToList(); + } + IDictionary projected = new Dictionary(StringComparer.Ordinal); + foreach (var mapping in mappings!) + projected.Add(mapping.Names[0], NormalizeWriteValue(mapping.FormatValue(mapping.GetValue(value)))); + yield return projected; + } + } + + public static IEnumerable> ToRows( + IEnumerable values, + IReadOnlyDictionary? dynamicColumns = null) + { + var mappings = CreateMappings( + typeof(T), + forWrite: true, + CultureInfo.InvariantCulture, + dynamicColumns) + .OrderBy(mapping => mapping.Index ?? int.MaxValue) + .ToList(); + foreach (var value in values) + { + if (value is null) + throw new ArgumentException("Typed export rows cannot contain null values.", nameof(values)); + IDictionary row = new Dictionary(StringComparer.Ordinal); + foreach (var mapping in mappings) + row.Add( + mapping.Names[0], + NormalizeWriteValue(mapping.FormatValue(mapping.GetValue(value)))); + yield return row; + } + } + + public static IEnumerable Map( + IEnumerable> rows, + CultureInfo? culture = null, + IReadOnlyDictionary? dynamicColumns = null) + where T : class, new() + { + culture ??= CultureInfo.InvariantCulture; + var mappings = CreateMappings(typeof(T), forWrite: false, culture, dynamicColumns); + var rowIndex = 1; + foreach (var row in rows) + { + var instance = new T(); + var values = row.Values.ToList(); + foreach (var mapping in mappings) + { + object? value = null; + var found = mapping.Index is int index + ? index >= 0 && index < values.Count && Assign(values[index], out value) + : TryGetValue(row, mapping.Names, out value); + if (!found) + throw new MiniExcelRustColumnNotFoundException(mapping.Names[0], rowIndex); + + try + { + mapping.SetValue(instance, ConvertValue(value, mapping.ValueType, mapping.Format, culture)); + } + catch (Exception error) when (error is InvalidCastException or FormatException or OverflowException or ArgumentException) + { + throw new MiniExcelRustMappingException( + mapping.Names[0], + rowIndex, + value, + mapping.ValueType, + error); + } + } + yield return instance; + rowIndex++; + } + } + + private static IReadOnlyList CreateMappings( + Type type, + bool forWrite, + CultureInfo culture, + IReadOnlyDictionary? dynamicColumns) + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public; + var members = type.GetProperties(flags) + .Where(property => + property.GetIndexParameters().Length == 0 && + (forWrite ? property.GetMethod is not null : property.SetMethod is not null)) + .Cast() + .Concat(type.GetFields(flags).Where(HasMiniExcelAttribute)); + return members + .Where(member => !IsIgnored(member)) + .Select(member => CreateMapping(member, culture, dynamicColumns)) + .Where(mapping => !mapping.Ignore) + .ToList(); + } + + private static MemberMapping CreateMapping( + MemberInfo member, + CultureInfo culture, + IReadOnlyDictionary? dynamicColumns) + { + var names = new List { member.Name }; + int? index = null; + string? format = null; + Type? resourceType = null; + foreach (var attribute in member.CustomAttributes) + { + var name = attribute.AttributeType.Name; + if (name is "ExcelColumnNameAttribute" or "MiniExcelColumnNameAttribute") + { + AddConstructorName(attribute, names); + AddNamedString(attribute, "Name", names); + AddAliases(attribute, names); + resourceType = ReadNamedType(attribute, "ResourceType") ?? resourceType; + } + else if (name is "ExcelColumnIndexAttribute" or "MiniExcelColumnIndexAttribute") + { + index = ReadIndex(attribute); + } + else if (name is "ExcelColumnAttribute" or "MiniExcelColumnAttribute") + { + AddNamedString(attribute, "Name", names); + AddAliases(attribute, names); + index = ReadNamedInt(attribute, "Index") ?? index; + format = ReadNamedString(attribute, "Format") ?? format; + resourceType = ReadNamedType(attribute, "ResourceType") ?? resourceType; + } + else if (name is "ExcelFormatAttribute" or "MiniExcelFormatAttribute") + { + format = attribute.ConstructorArguments.FirstOrDefault().Value as string; + } + } + + if (member.GetCustomAttribute() is { DisplayName.Length: > 0 } display) + names.Insert(0, display.DisplayName); + if (resourceType is not null) + names[0] = GetLocalizedName(resourceType, names[0], culture); + + var dynamicColumn = dynamicColumns is not null && dynamicColumns.TryGetValue(member.Name, out var configured) + ? configured + : null; + if (!string.IsNullOrWhiteSpace(dynamicColumn?.Name)) + names.Insert(0, dynamicColumn!.Name!); + index = dynamicColumn?.Index ?? index; + format = dynamicColumn?.Format ?? format; + + var valueType = member is PropertyInfo property ? property.PropertyType : ((FieldInfo)member).FieldType; + return new MemberMapping( + member, + names.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(), + index, + valueType, + format, + dynamicColumn?.Ignore is true, + dynamicColumn?.CustomFormatter); + } + + private static bool TryGetValue( + IDictionary row, + IReadOnlyList names, + out object? value) + { + foreach (var name in names) + { + if (row.TryGetValue(name, out value)) + return true; + var match = row.FirstOrDefault(cell => string.Equals(cell.Key, name, StringComparison.OrdinalIgnoreCase)); + if (match.Key is not null) + { + value = match.Value; + return true; + } + } + value = null; + return false; + } + + private static bool Assign(object? source, out object? value) + { + value = source; + return true; + } + + private static object? ConvertValue( + object? value, + Type targetType, + string? format, + CultureInfo culture) + { + if (value is null || value is DBNull) + { + if (!targetType.IsValueType || Nullable.GetUnderlyingType(targetType) is not null) + return null; + return Activator.CreateInstance(targetType); + } + + var effectiveType = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (effectiveType.IsInstanceOfType(value)) + return value; + if (effectiveType == typeof(string)) + return Convert.ToString(value, culture); + if (effectiveType == typeof(Guid)) + return Guid.Parse(Convert.ToString(value, culture)!); + if (effectiveType == typeof(Uri)) + return new Uri(Convert.ToString(value, culture)!, UriKind.RelativeOrAbsolute); + if (effectiveType == typeof(DateTime)) + { + if (value is double serial) + return DateTime.FromOADate(serial); + var text = Convert.ToString(value, culture)!; + return format is null + ? DateTime.Parse(text, culture) + : DateTime.ParseExact(text, format, CultureInfo.InvariantCulture); + } + if (effectiveType == typeof(DateTimeOffset)) + return DateTimeOffset.Parse(Convert.ToString(value, culture)!, culture); + if (effectiveType == typeof(TimeSpan)) + { + if (value is double milliseconds) + return TimeSpan.FromMilliseconds(milliseconds); + var text = Convert.ToString(value, culture)!; + return format is null + ? TimeSpan.Parse(text, culture) + : TimeSpan.ParseExact(text, format, CultureInfo.InvariantCulture); + } + if (effectiveType == typeof(bool)) + { + var text = Convert.ToString(value, culture); + return text switch { "1" => true, "0" => false, _ => bool.Parse(text!) }; + } + if (effectiveType.IsEnum) + { + var text = Convert.ToString(value, culture)!; + var described = effectiveType.GetFields() + .FirstOrDefault(field => field.GetCustomAttribute()?.Description == text); + return Enum.Parse(effectiveType, described?.Name ?? text, ignoreCase: true); + } + return Convert.ChangeType(value, effectiveType, culture); + } + + private static object? NormalizeWriteValue(object? value) + { + if (value is null) + return null; + var type = value.GetType(); + if (type.IsEnum) + { + var field = type.GetField(value.ToString()!); + return field?.GetCustomAttribute()?.Description ?? value.ToString(); + } + if (value is Guid or Uri) + return value.ToString(); + return value; + } + + private static bool HasMiniExcelAttribute(MemberInfo member) => + member.CustomAttributes.Any(attribute => attribute.AttributeType.Name.IndexOf("Excel", StringComparison.Ordinal) >= 0); + + private static bool IsIgnored(MemberInfo member) => member.CustomAttributes.Any(attribute => + (attribute.AttributeType.Name is "ExcelIgnoreAttribute" or "MiniExcelIgnoreAttribute" && + (attribute.ConstructorArguments.Count == 0 || attribute.ConstructorArguments[0].Value is not false)) || + (attribute.AttributeType.Name is "ExcelColumnAttribute" or "MiniExcelColumnAttribute" && + attribute.NamedArguments.Any(argument => argument.MemberName == "Ignore" && argument.TypedValue.Value is true))); + + private static void AddConstructorName(CustomAttributeData attribute, IList names) + { + if (attribute.ConstructorArguments.Count > 0 && attribute.ConstructorArguments[0].Value is string value && value.Length > 0) + names.Insert(0, value); + } + + private static void AddNamedString(CustomAttributeData attribute, string propertyName, IList names) + { + var argument = attribute.NamedArguments.FirstOrDefault(item => item.MemberName == propertyName); + if (argument.TypedValue.Value is string value && value.Length > 0) + names.Insert(0, value); + } + + private static void AddAliases(CustomAttributeData attribute, ICollection names) + { + if (attribute.ConstructorArguments.Count > 1 && + attribute.ConstructorArguments[1].Value is IEnumerable constructorAliases) + { + foreach (var alias in constructorAliases) + { + if (alias.Value is string value && value.Length > 0) + names.Add(value); + } + } + var argument = attribute.NamedArguments.FirstOrDefault(item => item.MemberName == "Aliases"); + if (argument.TypedValue.Value is IEnumerable aliases) + { + foreach (var alias in aliases) + { + if (alias.Value is string value && value.Length > 0) + names.Add(value); + } + } + } + + private static int? ReadIndex(CustomAttributeData attribute) + { + if (attribute.ConstructorArguments.Count == 0) + return null; + var value = attribute.ConstructorArguments[0].Value; + if (value is int index) + return index; + if (value is string columnName) + return ColumnNameToIndex(columnName); + return null; + } + + private static int? ReadNamedInt(CustomAttributeData attribute, string propertyName) + { + var argument = attribute.NamedArguments.FirstOrDefault(item => item.MemberName == propertyName); + return argument.TypedValue.Value is int value && value >= 0 ? value : null; + } + + private static string? ReadNamedString(CustomAttributeData attribute, string propertyName) + { + var argument = attribute.NamedArguments.FirstOrDefault(item => item.MemberName == propertyName); + return argument.TypedValue.Value as string; + } + + private static Type? ReadNamedType(CustomAttributeData attribute, string propertyName) + { + var argument = attribute.NamedArguments.FirstOrDefault(item => item.MemberName == propertyName); + return argument.TypedValue.Value as Type; + } + + private static string GetLocalizedName(Type resourceType, string key, CultureInfo culture) + { + const BindingFlags flags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + var manager = resourceType.GetProperty(nameof(ResourceManager), flags)?.GetValue(null) as ResourceManager + ?? new ResourceManager(resourceType); + return manager.GetString(key, culture) ?? key; + } + + private static int ColumnNameToIndex(string columnName) + { + var index = 0; + foreach (var character in columnName.ToUpperInvariant()) + { + if (character is < 'A' or > 'Z') + throw new ArgumentException($"Invalid Excel column name '{columnName}'."); + index = checked(index * 26 + character - 'A' + 1); + } + return index - 1; + } + + private sealed class MemberMapping( + MemberInfo member, + string[] names, + int? index, + Type valueType, + string? format, + bool ignore, + Func? customFormatter) + { + public string[] Names { get; } = names; + public int? Index { get; } = index; + public Type ValueType { get; } = valueType; + public string? Format { get; } = format; + public bool Ignore { get; } = ignore; + + public void SetValue(object target, object? value) + { + if (member is PropertyInfo property) + property.SetValue(target, value); + else + ((FieldInfo)member).SetValue(target, value); + } + + public object? GetValue(object target) + { + return member is PropertyInfo property + ? property.GetValue(target) + : ((FieldInfo)member).GetValue(target); + } + + public object? FormatValue(object? value) + { + if (customFormatter is null) + return value; + try + { + return customFormatter(value); + } + catch + { + return value; + } + } + } +} \ No newline at end of file diff --git a/dotnet/src/MiniExcel.Rust/MiniExcelRustMappingException.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRustMappingException.cs new file mode 100644 index 0000000..bf44e9c --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/MiniExcelRustMappingException.cs @@ -0,0 +1,36 @@ +namespace MiniExcelLibs; + +public sealed class MiniExcelRustMappingException : InvalidOperationException +{ + internal MiniExcelRustMappingException( + string columnName, + int row, + object? value, + Type targetType, + Exception innerException) + : base($"The value {value} in column {columnName} at row {row} cannot be assigned to {targetType.Name}.", innerException) + { + ColumnName = columnName; + Row = row; + Value = value; + TargetType = targetType; + } + + public string ColumnName { get; } + public int Row { get; } + public object? Value { get; } + public Type TargetType { get; } +} + +public sealed class MiniExcelRustColumnNotFoundException : InvalidOperationException +{ + internal MiniExcelRustColumnNotFoundException(string columnName, int row) + : base($"The mapped column {columnName} was not found at row {row}.") + { + ColumnName = columnName; + Row = row; + } + + public string ColumnName { get; } + public int Row { get; } +} \ No newline at end of file diff --git a/dotnet/src/MiniExcel.Rust/MiniExcelRustPicture.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRustPicture.cs new file mode 100644 index 0000000..375cc35 --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/MiniExcelRustPicture.cs @@ -0,0 +1,20 @@ +namespace MiniExcelLibs; + +public enum MiniExcelRustPictureAnchor : byte +{ + OneCell, + Absolute, + TwoCell +} + +public sealed class MiniExcelRustPicture +{ + public byte[] ImageBytes { get; set; } = Array.Empty(); + public string? SheetName { get; set; } + public string CellAddress { get; set; } = "A1"; + public int WidthPx { get; set; } = 80; + public int HeightPx { get; set; } = 24; + public MiniExcelRustPictureAnchor Anchor { get; set; } + public int LocationX { get; set; } + public int LocationY { get; set; } +} \ No newline at end of file diff --git a/dotnet/src/MiniExcel.Rust/MiniExcelRustRange.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRustRange.cs new file mode 100644 index 0000000..f78372b --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/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/dotnet/src/MiniExcel.Rust/MiniExcelRustReadOptions.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRustReadOptions.cs new file mode 100644 index 0000000..88752fa --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/MiniExcelRustReadOptions.cs @@ -0,0 +1,32 @@ +namespace MiniExcelLibs; + +/// +/// Configures Rust-backed XLSX queries. +/// +public sealed class MiniExcelRustReadOptions +{ + public static MiniExcelRustReadOptions FromMiniExcel( + OpenXml.OpenXmlConfiguration configuration) => + MiniExcelV1Adapters.ToReadOptions(configuration); + + public static implicit operator MiniExcelRustReadOptions( + OpenXml.OpenXmlConfiguration configuration) => + FromMiniExcel(configuration); + + public System.Globalization.CultureInfo Culture { get; set; } = System.Globalization.CultureInfo.InvariantCulture; + + public IDictionary DynamicColumns { get; } = + new Dictionary(StringComparer.Ordinal); + + public bool IgnoreEmptyRows { get; set; } + + public bool FillMergedCells { get; set; } + + public bool TrimColumnNames { get; set; } = true; + + public bool EnableSharedStringCache { get; set; } = true; + + public ulong SharedStringCacheSize { get; set; } = 5 * 1024 * 1024; + + public string? SharedStringCachePath { get; set; } = Path.GetTempPath(); +} \ No newline at end of file diff --git a/dotnet/src/MiniExcel.Rust/MiniExcelRustSheetInfo.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRustSheetInfo.cs new file mode 100644 index 0000000..1d45b1e --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/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/dotnet/src/MiniExcel.Rust/MiniExcelRustWriteOptions.cs b/dotnet/src/MiniExcel.Rust/MiniExcelRustWriteOptions.cs new file mode 100644 index 0000000..d731003 --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/MiniExcelRustWriteOptions.cs @@ -0,0 +1,47 @@ +namespace MiniExcelLibs; + +public enum MiniExcelRustHorizontalAlignment { Left, Center, Right } +public enum MiniExcelRustVerticalAlignment { Bottom, Center, Top } +public enum MiniExcelRustTableStyle { None, Default } + +/// +/// Configures Rust-backed XLSX writes. +/// +public sealed class MiniExcelRustWriteOptions +{ + public static MiniExcelRustWriteOptions FromMiniExcel( + OpenXml.OpenXmlConfiguration configuration) => + MiniExcelV1Adapters.ToWriteOptions(configuration); + + public static implicit operator MiniExcelRustWriteOptions( + OpenXml.OpenXmlConfiguration configuration) => + FromMiniExcel(configuration); + + public string SheetName { get; set; } = "Sheet1"; + public bool OverwriteFile { get; set; } + public bool PrintHeader { get; set; } = true; + public bool AutoFilter { get; set; } = true; + public bool RightToLeft { get; set; } + public bool AutoWidth { get; set; } + public bool WrapCellContents { get; set; } + public MiniExcelRustHorizontalAlignment HorizontalAlignment { get; set; } + public MiniExcelRustVerticalAlignment VerticalAlignment { get; set; } + public MiniExcelRustTableStyle TableStyle { get; set; } = MiniExcelRustTableStyle.Default; + public bool HeaderWrapText { get; set; } + public string HeaderBackgroundColor { get; set; } = "4472C4"; + public MiniExcelRustHorizontalAlignment HeaderHorizontalAlignment { get; set; } + public MiniExcelRustVerticalAlignment HeaderVerticalAlignment { get; set; } + public double MinWidth { get; set; } = 8.42857143; + public double MaxWidth { get; set; } = 200; + public uint FreezeRowCount { get; set; } = 1; + public ushort FreezeColumnCount { get; set; } + public string DateFormat { get; set; } = "yyyy-mm-dd"; + public string TimeFormat { get; set; } = "hh:mm:ss"; + public string DateTimeFormat { get; set; } = "yyyy-mm-dd hh:mm:ss"; + public string DurationFormat { get; set; } = "[h]:mm:ss"; + public IDictionary ColumnFormats { get; } = new Dictionary(); + public IDictionary ColumnWidths { get; } = new Dictionary(); + public IDictionary HiddenColumns { get; } = new Dictionary(); + public IDictionary DynamicColumns { get; } = + new Dictionary(StringComparer.Ordinal); +} \ No newline at end of file diff --git a/dotnet/src/MiniExcel.Rust/MiniExcelV1Adapters.cs b/dotnet/src/MiniExcel.Rust/MiniExcelV1Adapters.cs new file mode 100644 index 0000000..4c264c8 --- /dev/null +++ b/dotnet/src/MiniExcel.Rust/MiniExcelV1Adapters.cs @@ -0,0 +1,126 @@ +using MiniExcelLibs.Attributes; +using MiniExcelLibs.Csv; +using MiniExcelLibs.OpenXml; + +namespace MiniExcelLibs; + +internal static class MiniExcelV1Adapters +{ + internal static MiniExcelRustReadOptions ToReadOptions(OpenXmlConfiguration configuration) + { + if (configuration is null) + throw new ArgumentNullException(nameof(configuration)); + if (configuration.SharedStringCacheSize < 0) + throw new ArgumentOutOfRangeException(nameof(configuration), "SharedStringCacheSize cannot be negative."); + + var options = new MiniExcelRustReadOptions + { + Culture = configuration.Culture, + FillMergedCells = configuration.FillMergedCells, + TrimColumnNames = configuration.TrimColumnNames, + IgnoreEmptyRows = configuration.IgnoreEmptyRows, + EnableSharedStringCache = configuration.EnableSharedStringCache, + SharedStringCacheSize = checked((ulong)configuration.SharedStringCacheSize), + SharedStringCachePath = configuration.SharedStringCachePath + }; + CopyDynamicColumns(configuration.DynamicColumns, options.DynamicColumns); + return options; + } + + internal static MiniExcelRustCsvReadOptions ToReadOptions(CsvConfiguration configuration) + { + if (configuration is null) + throw new ArgumentNullException(nameof(configuration)); + var options = new MiniExcelRustCsvReadOptions + { + Culture = configuration.Culture, + Delimiter = configuration.Seperator, + ReadEmptyStringAsNull = configuration.ReadEmptyStringAsNull + }; + CopyDynamicColumns(configuration.DynamicColumns, options.DynamicColumns); + return options; + } + + internal static MiniExcelRustWriteOptions ToWriteOptions(OpenXmlConfiguration configuration) + { + if (configuration is null) + throw new ArgumentNullException(nameof(configuration)); + var style = configuration.StyleOptions; + var header = style?.HeaderStyle; + var options = new MiniExcelRustWriteOptions + { + AutoFilter = configuration.AutoFilter, + RightToLeft = configuration.RightToLeft, + AutoWidth = configuration.EnableAutoWidth, + MinWidth = configuration.MinWidth, + MaxWidth = configuration.MaxWidth, + FreezeRowCount = checked((uint)configuration.FreezeRowCount), + FreezeColumnCount = checked((ushort)configuration.FreezeColumnCount), + TableStyle = configuration.TableStyles == TableStyles.None + ? MiniExcelRustTableStyle.None + : MiniExcelRustTableStyle.Default, + WrapCellContents = style?.WrapCellContents ?? false, + HorizontalAlignment = ToHorizontalAlignment(style?.HorizontalAlignment), + VerticalAlignment = ToVerticalAlignment(style?.VerticalAlignment), + HeaderWrapText = header?.WrapText ?? false, + HeaderBackgroundColor = header is null + ? "4472C4" + : $"{header.BackgroundColor.R:X2}{header.BackgroundColor.G:X2}{header.BackgroundColor.B:X2}", + HeaderHorizontalAlignment = ToHorizontalAlignment(header?.HorizontalAlignment), + HeaderVerticalAlignment = ToVerticalAlignment(header?.VerticalAlignment) + }; + + CopyDynamicColumns(configuration.DynamicColumns, options.DynamicColumns); + foreach (var column in configuration.DynamicColumns ?? Array.Empty()) + { + if (string.IsNullOrWhiteSpace(column.Key)) + continue; + if (!string.IsNullOrWhiteSpace(column.Format)) + options.ColumnFormats[column.Key] = column.Format; + options.ColumnWidths[column.Key] = column.Width; + options.HiddenColumns[column.Key] = column.Hidden; + } + return options; + } + + private static void CopyDynamicColumns( + IEnumerable? source, + IDictionary destination) + { + if (source is null) + return; + + foreach (var column in source) + { + if (string.IsNullOrWhiteSpace(column.Key)) + throw new ArgumentException("MiniExcel DynamicColumns entries must have a key.", nameof(source)); + destination[column.Key] = new MiniExcelRustDynamicColumn + { + Name = column.Name, + Index = column.Index >= 0 ? column.Index : null, + Format = column.Format, + Ignore = column.Ignore, + CustomFormatter = column.CustomFormatter is null + ? null + : value => column.CustomFormatter(value!), + IsFormula = column.Type == ColumnType.Formula + }; + } + } + + private static MiniExcelRustHorizontalAlignment ToHorizontalAlignment( + HorizontalCellAlignment? alignment) => alignment switch + { + HorizontalCellAlignment.Center => MiniExcelRustHorizontalAlignment.Center, + HorizontalCellAlignment.Right => MiniExcelRustHorizontalAlignment.Right, + _ => MiniExcelRustHorizontalAlignment.Left + }; + + private static MiniExcelRustVerticalAlignment ToVerticalAlignment( + VerticalCellAlignment? alignment) => alignment switch + { + VerticalCellAlignment.Center => MiniExcelRustVerticalAlignment.Center, + VerticalCellAlignment.Top => MiniExcelRustVerticalAlignment.Top, + _ => MiniExcelRustVerticalAlignment.Bottom + }; +} \ No newline at end of file diff --git a/dotnet/tests/MiniExcel.Rust.PackageTests/MiniExcel.Rust.PackageTests.csproj b/dotnet/tests/MiniExcel.Rust.PackageTests/MiniExcel.Rust.PackageTests.csproj new file mode 100644 index 0000000..d622f91 --- /dev/null +++ b/dotnet/tests/MiniExcel.Rust.PackageTests/MiniExcel.Rust.PackageTests.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + enable + enable + 0.1.0-dev + + + + + + + diff --git a/dotnet/tests/MiniExcel.Rust.PackageTests/Program.cs b/dotnet/tests/MiniExcel.Rust.PackageTests/Program.cs new file mode 100644 index 0000000..371d584 --- /dev/null +++ b/dotnet/tests/MiniExcel.Rust.PackageTests/Program.cs @@ -0,0 +1,84 @@ +using MiniExcelLibs; +using MiniExcelLibs.Attributes; +using MiniExcelLibs.Csv; +using MiniExcelLibs.OpenXml; + +var temporaryDirectory = Path.Combine(Path.GetTempPath(), $"miniexcel-rust-package-{Guid.NewGuid():N}"); +Directory.CreateDirectory(temporaryDirectory); +try +{ + var rustAssembly = typeof(MiniExcelRust).Assembly; + Require(rustAssembly.GetType("MiniExcelLibs.MiniExcel") is null, "MiniExcel.Rust must not duplicate the MiniExcel facade."); + Require(rustAssembly.GetType("MiniExcelLibs.IConfiguration") is null, "MiniExcel.Rust must reuse MiniExcel v1 configuration types."); + Require(typeof(IConfiguration).Assembly != rustAssembly, "IConfiguration must come from the MiniExcel dependency."); + + var inputPath = Path.Combine(temporaryDirectory, "input.xlsx"); + global::MiniExcelLibs.MiniExcel.SaveAs( + inputPath, + new[] + { + new Dictionary + { + ["Display Name"] = "Ada", + ["Score"] = 42 + } + }); + + var readConfiguration = new OpenXmlConfiguration + { + IgnoreEmptyRows = true, + FillMergedCells = true, + TrimColumnNames = true + }; + var row = MiniExcelRust.Query( + inputPath, + useHeaderRow: true, + configuration: readConfiguration).Single(); + Require((string)row["Display Name"]! == "Ada", "Dynamic XLSX query did not return the expected name."); + + var typed = MiniExcelRust.Query( + inputPath, + configuration: readConfiguration).Single(); + Require(typed.Name == "Ada" && typed.Score == 42, "MiniExcel v1 attributes were not honored by typed mapping."); + + var outputPath = Path.Combine(temporaryDirectory, "output.xlsx"); + var writeConfiguration = new OpenXmlConfiguration + { + AutoFilter = false, + FreezeRowCount = 0, + RightToLeft = true + }; + var count = MiniExcelRust.SaveAs( + outputPath, + new[] { new PackageRow { Name = "Grace", Score = 84 } }, + writeConfiguration); + Require(count == 1, "Rust-backed XLSX write returned an unexpected row count."); + var roundTrip = global::MiniExcelLibs.MiniExcel.Query(outputPath).Single(); + Require(roundTrip.Name == "Grace" && roundTrip.Score == 84, "MiniExcel v1 could not read the Rust-written workbook."); + + var csvPath = Path.Combine(temporaryDirectory, "input.csv"); + File.WriteAllText(csvPath, "Name;Score\r\nLinus;21\r\n"); + var csvConfiguration = new CsvConfiguration { Seperator = ';' }; + var csvRow = MiniExcelRust.QueryCsv(csvPath, useHeaderRow: true, configuration: csvConfiguration).Single(); + Require((string)csvRow["Name"]! == "Linus", "MiniExcel v1 CSV configuration was not applied."); + + Console.WriteLine("MiniExcel.Rust package smoke test passed."); +} +finally +{ + Directory.Delete(temporaryDirectory, recursive: true); +} + +static void Require(bool condition, string message) +{ + if (!condition) + throw new InvalidOperationException(message); +} + +internal sealed class PackageRow +{ + [ExcelColumn(Name = "Display Name")] + public string Name { get; set; } = string.Empty; + + public int Score { get; set; } +} diff --git a/miniexcel-ffi/Cargo.toml b/miniexcel-ffi/Cargo.toml index db36f3c..11bab5f 100644 --- a/miniexcel-ffi/Cargo.toml +++ b/miniexcel-ffi/Cargo.toml @@ -13,4 +13,14 @@ publish = false crate-type = ["cdylib"] [dependencies] -miniexcel = { path = "../miniexcel" } \ No newline at end of file +chrono.workspace = true +futures-executor.workspace = true +futures-util.workspace = true +miniexcel = { path = "../miniexcel", features = ["async"] } +quick-xml.workspace = true +serde_json.workspace = true +tempfile.workspace = true +zip.workspace = true + +[target.'cfg(windows)'.dependencies] +atomicwrites.workspace = true \ No newline at end of file diff --git a/miniexcel-ffi/src/lib.rs b/miniexcel-ffi/src/lib.rs index 7eeafac..a55e7e0 100644 --- a/miniexcel-ffi/src/lib.rs +++ b/miniexcel-ffi/src/lib.rs @@ -1,10 +1,25 @@ use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; use std::ffi::{CStr, c_char}; +use std::fs::File; +use std::io::{BufReader, ErrorKind, Read, Write}; use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::Path; use std::ptr; use std::str::FromStr; -use miniexcel::{CellReference, CellValue, DynamicRow, HeaderMode, MiniExcel, ReadOptions}; +use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime}; +use miniexcel::{ + CellMap, CellReference, CellValue, CommentPerson, CommentTimestamp, CsvConfiguration, + CsvEncoding, CsvReadOptions, CsvWriteOptions, DynamicRow, ExistingSheetPolicy, HeaderMode, + HeaderStyle, HorizontalAlignment, InsertOptions, MergeSameCellsOptions, MiniExcel, ReadOptions, + RgbColor, SheetType, SheetVisibility, TableStyle, TargetRelationshipPolicy, TemplateOptions, + VerticalAlignment, WriteOptions, +}; +use quick_xml::Reader as XmlReader; +use quick_xml::events::{BytesStart, Event}; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; const ABI_VERSION: u32 = 1; const RESULT_END: i32 = 0; @@ -12,6 +27,7 @@ const RESULT_BATCH: i32 = 1; const ERROR_INVALID_ARGUMENT: i32 = -1; const ERROR_QUERY: i32 = -2; const ERROR_PANIC: i32 = -3; +const ERROR_WRITE: i32 = -4; thread_local! { static LAST_ERROR: RefCell> = const { RefCell::new(Vec::new()) }; @@ -22,6 +38,185 @@ pub struct QueryHandle { frame: Vec, } +struct PhysicalRowIterator { + inner: Box> + Send>, + pattern: std::vec::IntoIter, + columns: Vec, + start_column: usize, + normalize_merged_cells: bool, + merged_ranges: Vec, + merge_anchor_values: Vec>, +} + +enum PhysicalRowAction { + Data { row: usize, columns: Vec }, + Empty, + Skip, +} + +#[derive(Clone, Copy)] +struct MergedRangeInfo { + start_row: usize, + start_column: usize, + end_row: usize, + end_column: usize, +} + +impl Iterator for PhysicalRowIterator { + type Item = miniexcel::Result; + + fn next(&mut self) -> Option { + for action in self.pattern.by_ref() { + match action { + PhysicalRowAction::Empty => { + let row = self + .columns + .iter() + .cloned() + .map(|column| (column, CellValue::Empty)) + .collect(); + return Some(Ok(row)); + } + PhysicalRowAction::Data { row: row_index, columns: physical_columns } => { + let mut row = self.inner.next()?; + if self.normalize_merged_cells { + if let Ok(row) = row.as_mut() { + for (offset, (_, value)) in row.iter_mut().enumerate() { + let column = self.start_column + offset; + if !physical_columns.contains(&column) { + *value = CellValue::Empty; + } + } + for (index, range) in self.merged_ranges.iter().enumerate() { + if row_index == range.start_row { + let offset = + range.start_column.saturating_sub(self.start_column); + self.merge_anchor_values[index] = + row.get_index(offset).map(|(_, value)| value.clone()); + } + if row_index < range.start_row || row_index > range.end_row { + continue; + } + let Some(anchor) = self.merge_anchor_values[index].as_ref() else { + continue; + }; + for column in range.start_column..=range.end_column { + if column == range.start_column && row_index == range.start_row + { + continue; + } + if physical_columns.contains(&column) { + let offset = column.saturating_sub(self.start_column); + if let Some((_, value)) = row.get_index_mut(offset) { + if value.is_empty() { + *value = anchor.clone(); + } + } + } + } + } + } + } + return Some(row); + } + PhysicalRowAction::Skip => { + if let Err(error) = self.inner.next()? { + return Some(Err(error)); + } + } + } + } + self.inner.next() + } +} + +pub struct BufferHandle { + frame: Vec, +} + +pub struct CancellationHandle { + token: miniexcel::CancellationToken, +} + +struct QueryOpenOptions { + path: *const c_char, + use_header_row: u8, + sheet_name: *const c_char, + start_cell: *const c_char, + end_cell: *const c_char, + ignore_empty_rows: u8, + fill_merged_cells: u8, + trim_headers: u8, + enable_shared_string_cache: u8, + shared_string_cache_size: u64, + shared_string_cache_path: *const c_char, +} + +struct CsvWriteArguments { + path: *const c_char, + data: *const u8, + data_length: usize, + delimiter: u8, + encoding: u8, + write_bom: u8, + print_header: u8, + overwrite_file: u8, +} + +struct SpoolRows { + reader: BufReader, + finished: bool, +} + +impl SpoolRows { + fn open(path: impl AsRef) -> std::io::Result { + Ok(Self { reader: BufReader::new(File::open(path)?), finished: false }) + } +} + +impl Iterator for SpoolRows { + type Item = miniexcel::Result; + + fn next(&mut self) -> Option { + if self.finished { + return None; + } + let mut length = [0_u8; 4]; + if let Err(error) = self.reader.read_exact(&mut length) { + self.finished = true; + return if error.kind() == ErrorKind::UnexpectedEof { + None + } else { + Some(Err(error.into())) + }; + } + let length = u32::from_le_bytes(length) as usize; + let mut frame = vec![0_u8; length]; + if let Err(error) = self.reader.read_exact(&mut frame) { + self.finished = true; + return Some(Err(error.into())); + } + match decode_rows(&frame) { + Ok(mut rows) if rows.len() == 1 => Some(Ok(rows.remove(0))), + Ok(_) => { + self.finished = true; + Some(Err(std::io::Error::new( + ErrorKind::InvalidData, + "spool frame must contain exactly one row", + ) + .into())) + } + Err(_) => { + self.finished = true; + Some(Err(std::io::Error::new(ErrorKind::InvalidData, "spool row frame is invalid") + .into())) + } + } + } +} + +type DeclaredDimension = (Option, Option); + #[unsafe(no_mangle)] pub extern "C" fn miniexcel_abi_version() -> u32 { ABI_VERSION @@ -40,36 +235,181 @@ 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 })?; @@ -79,6 +419,57 @@ 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 @@ -144,103 +535,2825 @@ pub unsafe extern "C" fn miniexcel_query_close(handle: *mut QueryHandle) { } } -/// Returns the last error recorded on the current native thread. +/// Returns worksheet names in workbook order using memory owned by an opaque buffer handle. /// /// # Safety /// -/// `out_length` may be null; otherwise it must be writable. The returned data remains valid until -/// the next MiniExcel FFI error on this thread. +/// `path`, `out_handle`, `out_data`, and `out_length` must be non-null and valid for the duration +/// of the call. Returned data remains valid until `miniexcel_buffer_close` closes the handle. #[unsafe(no_mangle)] -pub unsafe extern "C" fn miniexcel_last_error(out_length: *mut usize) -> *const u8 { - LAST_ERROR.with(|error| { - let error = error.borrow(); - if !out_length.is_null() { - unsafe { ptr::write(out_length, error.len()) }; +pub unsafe extern "C" fn miniexcel_get_sheet_names( + path: *const c_char, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() || out_handle.is_null() || out_data.is_null() || out_length.is_null() { + set_last_error("path, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); } - error.as_ptr() - }) -} -fn ffi_result(operation: impl FnOnce() -> Result) -> i32 { - match catch_unwind(AssertUnwindSafe(operation)) { - Ok(Ok(result)) => result, - Ok(Err(code)) => code, - Err(_) => { - set_last_error("Rust panic crossed the MiniExcel FFI boundary"); - ERROR_PANIC + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); } - } -} -unsafe fn read_utf8<'a>(value: *const c_char) -> Result<&'a str, i32> { - unsafe { CStr::from_ptr(value) }.to_str().map_err(|error| { - set_last_error(error.to_string()); - ERROR_INVALID_ARGUMENT + let path = unsafe { read_utf8(path) }?; + let names = MiniExcel::get_sheet_names(path).map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let mut frame = Vec::new(); + write_length(&mut frame, names.len())?; + for name in names { + write_string(&mut frame, name)?; + } + + let handle = Box::new(BufferHandle { frame }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) }) } -fn set_last_error(message: impl AsRef) { - LAST_ERROR.with(|error| { - let mut error = error.borrow_mut(); - error.clear(); - error.extend_from_slice(message.as_ref().as_bytes()); - }); -} +/// Returns selected column names using memory owned by an opaque buffer handle. +/// +/// # Safety +/// +/// `path`, `start_cell`, and all output pointers must be non-null and valid for the duration of +/// the call. `sheet_name` may be null. Returned data remains valid until the handle is closed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_get_columns( + path: *const c_char, + use_header_row: u8, + sheet_name: *const c_char, + start_cell: *const c_char, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() + || start_cell.is_null() + || out_handle.is_null() + || out_data.is_null() + || out_length.is_null() + { + set_last_error("path, start_cell, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); + } -fn write_row(frame: &mut Vec, row: &DynamicRow) -> Result<(), i32> { - write_length(frame, row.len())?; - for (name, value) in row { - write_string(frame, name)?; - match value { - CellValue::Empty => frame.push(0), - CellValue::Bool(value) => { - frame.push(1); - frame.push(u8::from(*value)); - } - CellValue::Int(value) => { - frame.push(2); - frame.extend_from_slice(&value.to_le_bytes()); - } - CellValue::Float(value) => { - frame.push(3); - frame.extend_from_slice(&value.to_le_bytes()); - } - CellValue::String(value) => { - frame.push(4); - write_string(frame, value)?; - } - CellValue::Date(value) => { - frame.push(5); - write_string(frame, value.format("%Y-%m-%d").to_string())?; - } - CellValue::Time(value) => { - frame.push(6); - write_string(frame, value.format("%H:%M:%S%.f").to_string())?; - } - CellValue::DateTime(value) => { - frame.push(7); - 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()); - } - CellValue::Error(value) => { - frame.push(9); - write_string(frame, value)?; - } + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); } - } - Ok(()) -} -fn write_string(frame: &mut Vec, value: impl AsRef) -> Result<(), i32> { + 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 = declared_sheet_dimensions(path)?; + let mut frame = Vec::new(); + write_length(&mut frame, dimensions.len())?; + for (start_cell, end_cell) in dimensions { + write_string(&mut frame, start_cell.unwrap_or_default())?; + write_string(&mut frame, end_cell.unwrap_or_default())?; + } + + let handle = Box::new(BufferHandle { frame }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) + }) +} + +/// Returns worksheet metadata in workbook order. +/// +/// # Safety +/// +/// `path` and all output pointers must be non-null and valid for the duration of the call. +/// Returned data remains valid until the buffer handle is closed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_get_sheet_info( + path: *const c_char, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() || out_handle.is_null() || out_data.is_null() || out_length.is_null() { + set_last_error("path, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); + } + + let path = unsafe { read_utf8(path) }?; + let sheets = MiniExcel::get_sheet_info(path).map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let mut frame = Vec::new(); + write_length(&mut frame, sheets.len())?; + for sheet in sheets { + write_u32(&mut frame, sheet.id()); + write_length(&mut frame, sheet.index())?; + write_string(&mut frame, sheet.name())?; + frame.push(match sheet.sheet_type() { + SheetType::Worksheet => 0, + SheetType::DialogSheet => 1, + SheetType::MacroSheet => 2, + SheetType::ChartSheet => 3, + SheetType::Vba => 4, + }); + frame.push(match sheet.visibility() { + SheetVisibility::Visible => 0, + SheetVisibility::Hidden => 1, + SheetVisibility::VeryHidden => 2, + }); + frame.push(u8::from(sheet.is_active())); + } + + let handle = Box::new(BufferHandle { frame }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) + }) +} + +/// Returns threaded comments, replies, and legacy notes for a worksheet. +/// +/// # Safety +/// +/// `path` and all output pointers must be non-null and valid for the duration of the call. +/// `sheet_name` may be null. Returned data remains valid until the buffer handle is closed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_get_comments( + path: *const c_char, + sheet_name: *const c_char, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() || out_handle.is_null() || out_data.is_null() || out_length.is_null() { + set_last_error("path, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); + } + + let path = unsafe { read_utf8(path) }?; + let sheet_name = if sheet_name.is_null() { + None + } else { + let value = unsafe { read_utf8(sheet_name) }?; + (!value.is_empty()).then_some(value) + }; + let comments = MiniExcel::get_comments(path, sheet_name).map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let mut frame = Vec::new(); + write_string(&mut frame, comments.sheet_name())?; + write_length(&mut frame, comments.threaded_comments().len())?; + for comment in comments.threaded_comments() { + write_string(&mut frame, comment.id().to_string())?; + write_string(&mut frame, comment.cell().to_string())?; + write_person(&mut frame, comment.person())?; + write_timestamp(&mut frame, comment.created_at())?; + frame.push(u8::from(comment.resolved())); + write_string(&mut frame, comment.text())?; + write_length(&mut frame, comment.replies().len())?; + for reply in comment.replies() { + write_string(&mut frame, reply.id().to_string())?; + write_string(&mut frame, reply.parent_id().to_string())?; + write_person(&mut frame, reply.person())?; + write_timestamp(&mut frame, reply.created_at())?; + write_string(&mut frame, reply.text())?; + } + } + write_length(&mut frame, comments.notes().len())?; + for note in comments.notes() { + write_optional_string(&mut frame, note.id().map(|id| id.to_string()).as_deref())?; + write_string(&mut frame, note.cell().to_string())?; + write_optional_string(&mut frame, note.author())?; + write_string(&mut frame, note.text())?; + } + + let handle = Box::new(BufferHandle { frame }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) + }) +} + +/// Reads explicitly mapped worksheet cells into one dynamic row. +/// +/// # Safety +/// +/// `path`, `mapping_data`, and all output pointers must be valid for supplied lengths. +/// `sheet_name` may be null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_read_mapped( + path: *const c_char, + sheet_name: *const c_char, + mapping_data: *const u8, + mapping_length: usize, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() + || mapping_data.is_null() + || out_handle.is_null() + || out_data.is_null() + || out_length.is_null() + { + set_last_error("path, mapping_data, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); + } + let path = unsafe { read_utf8(path) }?; + let mut reader = + FrameInput::new(unsafe { std::slice::from_raw_parts(mapping_data, mapping_length) }); + let count = reader.read_length()?; + let mut mapping = CellMap::new(); + let mut fields = Vec::with_capacity(count); + if !sheet_name.is_null() { + let sheet_name = unsafe { read_utf8(sheet_name) }?; + if !sheet_name.is_empty() { + mapping = mapping.with_sheet_name(sheet_name); + } + } + for _ in 0..count { + let field = reader.read_string()?; + let cell = CellReference::from_str(&reader.read_string()?).map_err(|error| { + set_last_error(error.to_string()); + ERROR_INVALID_ARGUMENT + })?; + mapping = mapping.with_cell(&field, cell); + fields.push(field); + } + reader.ensure_complete()?; + let mut values = + MiniExcel::read_mapped_as::>(path, &mapping) + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let mut row = DynamicRow::with_capacity(fields.len()); + for field in fields { + let value = values.remove(&field).unwrap_or(serde_json::Value::Null); + row.insert(field, json_value_to_cell(value)?); + } + let mut frame = Vec::new(); + write_u32(&mut frame, 1); + write_row(&mut frame, &row)?; + let handle = Box::new(BufferHandle { frame }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) + }) +} + +fn json_value_to_cell(value: serde_json::Value) -> Result { + match value { + serde_json::Value::Null => Ok(CellValue::Empty), + serde_json::Value::Bool(value) => Ok(CellValue::Bool(value)), + serde_json::Value::Number(value) => { + if let Some(integer) = value.as_i64() { + Ok(CellValue::Int(integer)) + } else if let Some(unsigned) = value.as_u64() { + i64::try_from(unsigned).map(CellValue::Int).map_err(|_| { + set_last_error("mapped unsigned integer exceeds Int64"); + ERROR_QUERY + }) + } else { + value.as_f64().map(CellValue::Float).ok_or_else(|| { + set_last_error("mapped JSON number is not representable"); + ERROR_QUERY + }) + } + } + serde_json::Value::String(value) => Ok(CellValue::String(value)), + _ => { + set_last_error("mapped cell produced a non-scalar JSON value"); + Err(ERROR_QUERY) + } + } +} + +/// Creates a single-sheet XLSX workbook from encoded dynamic rows. +/// +/// # Safety +/// +/// `path`, `data`, and `out_row_count` must be non-null and valid for the supplied lengths. +/// `sheet_name` may be null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_save_as( + path: *const c_char, + data: *const u8, + data_length: usize, + print_header: u8, + sheet_name: *const c_char, + overwrite_file: u8, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| { + if path.is_null() || data.is_null() || out_row_count.is_null() { + set_last_error("path, data, and out_row_count are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + + unsafe { ptr::write(out_row_count, 0) }; + let path = unsafe { read_utf8(path) }?; + let bytes = unsafe { std::slice::from_raw_parts(data, data_length) }; + let rows = decode_rows(bytes)?; + let mut options = WriteOptions::new() + .with_print_header(print_header != 0) + .with_overwrite_file(overwrite_file != 0); + if !sheet_name.is_null() { + let sheet_name = unsafe { read_utf8(sheet_name) }?; + if !sheet_name.is_empty() { + options = options.with_sheet_name(sheet_name); + } + } + MiniExcel::save_as_with_options(path, &rows, &options).map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + let row_count = u32::try_from(rows.len()).map_err(|_| { + set_last_error("row count exceeds the ABI limit"); + ERROR_WRITE + })?; + unsafe { ptr::write(out_row_count, row_count) }; + Ok(RESULT_BATCH) + }) +} + +/// Creates a multi-sheet XLSX workbook from an ordered encoded sheet collection. +/// +/// # Safety +/// +/// `path`, `data`, and all output pointers must be valid for the supplied lengths. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_save_as_sheets( + path: *const c_char, + data: *const u8, + data_length: usize, + print_header: u8, + overwrite_file: u8, + out_handle: *mut *mut BufferHandle, + out_data: *mut *const u8, + out_length: *mut usize, +) -> i32 { + ffi_result(|| { + if path.is_null() + || data.is_null() + || out_handle.is_null() + || out_data.is_null() + || out_length.is_null() + { + set_last_error("path, data, out_handle, out_data, and out_length are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { + ptr::write(out_handle, ptr::null_mut()); + ptr::write(out_data, ptr::null()); + ptr::write(out_length, 0); + } + let path = unsafe { read_utf8(path) }?; + let sheets = decode_sheets(unsafe { std::slice::from_raw_parts(data, data_length) })?; + let options = WriteOptions::new() + .with_print_header(print_header != 0) + .with_overwrite_file(overwrite_file != 0); + let counts = MiniExcel::save_as_sheets( + path, + sheets.iter().map(|(name, rows)| (name, rows.as_slice())), + &options, + ) + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + let mut frame = Vec::new(); + write_length(&mut frame, counts.len())?; + for count in counts { + write_length(&mut frame, count)?; + } + let handle = Box::new(BufferHandle { frame }); + unsafe { + ptr::write(out_data, handle.frame.as_ptr()); + ptr::write(out_length, handle.frame.len()); + ptr::write(out_handle, Box::into_raw(handle)); + } + Ok(RESULT_BATCH) + }) +} + +/// Creates an XLSX workbook from dynamic rows and a JSON write-options payload. +/// +/// # Safety +/// +/// `path`, `data`, `options_json`, and `out_row_count` must be valid for supplied lengths. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_save_as_configured( + path: *const c_char, + data: *const u8, + data_length: usize, + options_json: *const u8, + options_length: usize, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| { + if path.is_null() || data.is_null() || options_json.is_null() || out_row_count.is_null() { + set_last_error("path, data, options_json, and out_row_count are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { ptr::write(out_row_count, 0) }; + let path = unsafe { read_utf8(path) }?; + let mut rows = decode_rows(unsafe { std::slice::from_raw_parts(data, data_length) })?; + let payload: serde_json::Value = serde_json::from_slice(unsafe { + std::slice::from_raw_parts(options_json, options_length) + }) + .map_err(|error| { + set_last_error(format!("invalid write-options JSON: {error}")); + ERROR_INVALID_ARGUMENT + })?; + let options = configured_write_options(&payload)?; + let schema = configured_schema(&payload)?; + let formula_columns = configured_formula_columns(&payload)?; + if formula_columns.is_empty() { + write_configured_workbook(path, &rows, schema.as_deref(), &options)?; + } else { + for row in &mut rows { + for column in &formula_columns { + if let Some(value) = row.get_mut(column) { + let CellValue::String(formula) = value else { + set_last_error(format!( + "formula column '{column}' requires string values" + )); + return Err(ERROR_INVALID_ARGUMENT); + }; + let formula = formula.strip_prefix('=').unwrap_or(formula); + *value = CellValue::String(format!("$={formula}")); + } + } + } + let destination = Path::new(path); + let parent = destination.parent().unwrap_or_else(|| Path::new(".")); + let staging = tempfile::Builder::new() + .prefix(".miniexcel-formula-") + .suffix(".xlsx") + .tempfile_in(parent) + .map_err(write_error)? + .into_temp_path(); + std::fs::remove_file(&staging).map_err(write_error)?; + let staging_options = options.clone().with_overwrite_file(false); + let staging_path: &Path = staging.as_ref(); + write_configured_workbook(staging_path, &rows, schema.as_deref(), &staging_options)?; + let template_options = TemplateOptions::new() + .with_overwrite_file(json_bool(&payload, "overwriteFile", false)?) + .with_ignore_missing_variables(true); + MiniExcel::save_as_template(path, &staging, &serde_json::json!({}), &template_options) + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + } + write_row_count(rows.len(), out_row_count) + }) +} + +/// Creates a cancellable XLSX export by consuming framed rows from a spool file once. +/// +/// # Safety +/// +/// All pointers must be non-null, valid, and remain alive for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_save_as_spooled_async( + path: *const c_char, + spool_path: *const c_char, + options_json: *const u8, + options_length: usize, + cancellation: *mut CancellationHandle, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| { + if path.is_null() + || spool_path.is_null() + || options_json.is_null() + || cancellation.is_null() + || out_row_count.is_null() + { + set_last_error( + "path, spool_path, options_json, cancellation, and out_row_count are required", + ); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { ptr::write(out_row_count, 0) }; + let path = unsafe { read_utf8(path) }?; + let spool_path = unsafe { read_utf8(spool_path) }?; + let payload: serde_json::Value = serde_json::from_slice(unsafe { + std::slice::from_raw_parts(options_json, options_length) + }) + .map_err(|error| { + set_last_error(format!("invalid write-options JSON: {error}")); + ERROR_INVALID_ARGUMENT + })?; + let schema = configured_schema(&payload)?.ok_or_else(|| { + set_last_error("async spool export requires an explicit schema"); + ERROR_INVALID_ARGUMENT + })?; + let options = configured_write_options(&payload)?; + let rows = SpoolRows::open(spool_path).map_err(write_error)?; + let rows = futures_util::stream::iter(rows); + let token = unsafe { &*cancellation }.token.clone(); + let count = + futures_executor::block_on(MiniExcel::save_as_with_schema_async_with_cancellation( + path, &schema, rows, &options, token, + )) + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + write_row_count(count, out_row_count) + }) +} + +/// Creates a cancellable CSV export by consuming framed rows from a spool file once. +/// +/// # Safety +/// +/// All pointers must be non-null, valid, and remain alive for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_save_csv_spooled_async( + path: *const c_char, + spool_path: *const c_char, + options_json: *const u8, + options_length: usize, + cancellation: *mut CancellationHandle, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| { + if path.is_null() + || spool_path.is_null() + || options_json.is_null() + || cancellation.is_null() + || out_row_count.is_null() + { + set_last_error( + "path, spool_path, options_json, cancellation, and out_row_count are required", + ); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { ptr::write(out_row_count, 0) }; + let path = unsafe { read_utf8(path) }?; + let spool_path = unsafe { read_utf8(spool_path) }?; + let payload: serde_json::Value = serde_json::from_slice(unsafe { + std::slice::from_raw_parts(options_json, options_length) + }) + .map_err(|error| { + set_last_error(format!("invalid CSV write-options JSON: {error}")); + ERROR_INVALID_ARGUMENT + })?; + let schema = configured_schema(&payload)?.ok_or_else(|| { + set_last_error("async CSV spool export requires an explicit schema"); + ERROR_INVALID_ARGUMENT + })?; + let configuration = CsvConfiguration::new() + .with_delimiter( + json_u64(&payload, "delimiter", b',' as u64)? + .try_into() + .map_err(|_| invalid_write_options("delimiter exceeds one byte"))?, + ) + .with_encoding(parse_csv_encoding( + json_u64(&payload, "encoding", 0)? + .try_into() + .map_err(|_| invalid_write_options("encoding exceeds one byte"))?, + )?) + .with_write_bom(json_bool(&payload, "writeBom", true)?); + let options = CsvWriteOptions::new() + .with_configuration(configuration) + .with_print_header(json_bool(&payload, "printHeader", true)?) + .with_overwrite_file(true); + let overwrite = json_bool(&payload, "overwriteFile", false)?; + let destination = Path::new(path); + if destination.exists() && !overwrite { + set_last_error(format!("destination '{}' already exists", destination.display())); + return Err(ERROR_WRITE); + } + let parent = destination.parent().unwrap_or_else(|| Path::new(".")); + let staging = tempfile::Builder::new() + .prefix(".miniexcel-csv-") + .suffix(".csv") + .tempfile_in(parent) + .map_err(write_error)? + .into_temp_path(); + let staging_path: &Path = staging.as_ref(); + let token = unsafe { &*cancellation }.token.clone(); + let rows = SpoolRows::open(spool_path).map_err(write_error)?; + let mut count = 0_usize; + for row in rows { + if token.is_cancelled() { + set_last_error("operation cancelled"); + return Err(ERROR_WRITE); + } + let row = row.map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + if count == 0 { + MiniExcel::save_csv_with_schema(staging_path, &schema, &[row], &options) + } else { + MiniExcel::append_csv_with_schema(staging_path, &schema, &[row], &options) + } + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + count += 1; + } + if count == 0 { + MiniExcel::save_csv_with_schema(staging_path, &schema, &[], &options).map_err( + |error| { + set_last_error(error.to_string()); + ERROR_WRITE + }, + )?; + } + if token.is_cancelled() { + set_last_error("operation cancelled"); + return Err(ERROR_WRITE); + } + publish_staged_file(staging_path, destination)?; + write_row_count(count, out_row_count) + }) +} + +#[unsafe(no_mangle)] +/// Creates a native cooperative cancellation handle. +/// +/// # Safety +/// +/// `out_handle` must be non-null and writable. +pub unsafe extern "C" fn miniexcel_cancellation_create( + out_handle: *mut *mut CancellationHandle, +) -> i32 { + ffi_result(|| { + if out_handle.is_null() { + set_last_error("out_handle is required"); + return Err(ERROR_INVALID_ARGUMENT); + } + let handle = Box::new(CancellationHandle { token: miniexcel::CancellationToken::new() }); + unsafe { ptr::write(out_handle, Box::into_raw(handle)) }; + Ok(RESULT_BATCH) + }) +} + +#[unsafe(no_mangle)] +/// Signals cooperative cancellation. +/// +/// # Safety +/// +/// `handle` must be null or a live cancellation handle returned by this library. +pub unsafe extern "C" fn miniexcel_cancellation_cancel(handle: *mut CancellationHandle) { + if !handle.is_null() { + unsafe { &*handle }.token.cancel(); + } +} + +#[unsafe(no_mangle)] +/// Releases a native cancellation handle. +/// +/// # Safety +/// +/// `handle` must be null or a live cancellation handle that has not already been closed. +pub unsafe extern "C" fn miniexcel_cancellation_close(handle: *mut CancellationHandle) { + if !handle.is_null() { + let _ = catch_unwind(AssertUnwindSafe(|| drop(unsafe { Box::from_raw(handle) }))); + } +} + +/// Creates a CSV file from encoded dynamic rows. +/// +/// # Safety +/// +/// `path`, `data`, and `out_row_count` must be non-null and valid for the supplied lengths. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_save_csv( + path: *const c_char, + data: *const u8, + data_length: usize, + delimiter: u8, + encoding: u8, + write_bom: u8, + print_header: u8, + overwrite_file: u8, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| unsafe { + write_csv( + CsvWriteArguments { + path, + data, + data_length, + delimiter, + encoding, + write_bom, + print_header, + overwrite_file, + }, + false, + out_row_count, + ) + }) +} + +/// Appends encoded dynamic rows to a CSV file. +/// +/// # Safety +/// +/// `path`, `data`, and `out_row_count` must be non-null and valid for the supplied lengths. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_append_csv( + path: *const c_char, + data: *const u8, + data_length: usize, + delimiter: u8, + encoding: u8, + write_bom: u8, + print_header: u8, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| unsafe { + write_csv( + CsvWriteArguments { + path, + data, + data_length, + delimiter, + encoding, + write_bom, + print_header, + overwrite_file: 0, + }, + true, + out_row_count, + ) + }) +} + +/// Inserts or replaces a worksheet in an XLSX workbook. +/// +/// # Safety +/// +/// `path`, `data`, `sheet_name`, and `out_row_count` must be valid for the supplied lengths. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_insert_sheet( + path: *const c_char, + data: *const u8, + data_length: usize, + sheet_name: *const c_char, + print_header: u8, + replace_existing: u8, + remove_supported_relationships: u8, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| { + if path.is_null() || data.is_null() || sheet_name.is_null() || out_row_count.is_null() { + set_last_error("path, data, sheet_name, and out_row_count are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { ptr::write(out_row_count, 0) }; + let path = unsafe { read_utf8(path) }?; + let sheet_name = unsafe { read_utf8(sheet_name) }?; + let rows = decode_rows(unsafe { std::slice::from_raw_parts(data, data_length) })?; + let options = insert_options( + sheet_name, + print_header, + replace_existing, + remove_supported_relationships, + false, + ); + let count = MiniExcel::insert(path, &rows, &options).map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + write_row_count(count, out_row_count) + }) +} + +/// Copies an XLSX workbook and adds or replaces one worksheet in the destination. +/// +/// # Safety +/// +/// Both paths, `data`, `sheet_name`, and `out_row_count` must be valid for supplied lengths. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_copy_and_add_sheet( + source_path: *const c_char, + destination_path: *const c_char, + data: *const u8, + data_length: usize, + sheet_name: *const c_char, + print_header: u8, + replace_existing: u8, + remove_supported_relationships: u8, + overwrite_destination: u8, + out_row_count: *mut u32, +) -> i32 { + ffi_result(|| { + if source_path.is_null() + || destination_path.is_null() + || data.is_null() + || sheet_name.is_null() + || out_row_count.is_null() + { + set_last_error( + "source_path, destination_path, data, sheet_name, and out_row_count are required", + ); + return Err(ERROR_INVALID_ARGUMENT); + } + unsafe { ptr::write(out_row_count, 0) }; + let source_path = unsafe { read_utf8(source_path) }?; + let destination_path = unsafe { read_utf8(destination_path) }?; + let sheet_name = unsafe { read_utf8(sheet_name) }?; + let rows = decode_rows(unsafe { std::slice::from_raw_parts(data, data_length) })?; + let options = insert_options( + sheet_name, + print_header, + replace_existing, + remove_supported_relationships, + overwrite_destination != 0, + ); + let count = MiniExcel::copy_and_add_sheet(source_path, destination_path, &rows, &options) + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + write_row_count(count, out_row_count) + }) +} + +/// Fills an XLSX template from a UTF-8 JSON value and atomically writes the destination. +/// +/// # Safety +/// +/// All string pointers must be non-null, valid, null-terminated UTF-8 for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_fill_template( + destination_path: *const c_char, + template_path: *const c_char, + json_data: *const u8, + json_length: usize, + overwrite_file: u8, + ignore_missing_variables: u8, +) -> i32 { + ffi_result(|| { + if destination_path.is_null() || template_path.is_null() || json_data.is_null() { + set_last_error("destination_path, template_path, and json_data are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + + let destination_path = unsafe { read_utf8(destination_path) }?; + let template_path = unsafe { read_utf8(template_path) }?; + let json = unsafe { std::slice::from_raw_parts(json_data, json_length) }; + let value: serde_json::Value = serde_json::from_slice(json).map_err(|error| { + set_last_error(format!("invalid template JSON: {error}")); + ERROR_INVALID_ARGUMENT + })?; + let options = TemplateOptions::new() + .with_overwrite_file(overwrite_file != 0) + .with_ignore_missing_variables(ignore_missing_variables != 0); + MiniExcel::save_as_template(destination_path, template_path, &value, &options).map_err( + |error| { + set_last_error(error.to_string()); + ERROR_WRITE + }, + )?; + Ok(RESULT_BATCH) + }) +} + +/// Overlays an expanded fluent-mapping cell plan onto an XLSX template. +/// +/// # Safety +/// +/// All string pointers must be non-null, valid, null-terminated UTF-8 for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_fill_mapped_template( + destination_path: *const c_char, + template_path: *const c_char, + json_data: *const u8, + json_length: usize, + overwrite_file: u8, +) -> i32 { + ffi_result(|| { + if destination_path.is_null() || template_path.is_null() || json_data.is_null() { + set_last_error("destination_path, template_path, and json_data are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + let destination_path = unsafe { read_utf8(destination_path) }?; + let template_path = unsafe { read_utf8(template_path) }?; + let payload: serde_json::Value = + serde_json::from_slice(unsafe { std::slice::from_raw_parts(json_data, json_length) }) + .map_err(|error| { + set_last_error(format!("invalid mapped template JSON: {error}")); + ERROR_INVALID_ARGUMENT + })?; + fill_mapped_template(destination_path, template_path, &payload, overwrite_file != 0)?; + Ok(RESULT_BATCH) + }) +} + +/// Merges tagged same-value cells into a separate XLSX destination. +/// +/// # Safety +/// +/// Both path pointers must be non-null, valid, null-terminated UTF-8 for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_merge_same_cells( + destination_path: *const c_char, + source_path: *const c_char, + overwrite_file: u8, +) -> i32 { + ffi_result(|| { + if destination_path.is_null() || source_path.is_null() { + set_last_error("destination_path and source_path are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + let destination_path = unsafe { read_utf8(destination_path) }?; + let source_path = unsafe { read_utf8(source_path) }?; + let options = MergeSameCellsOptions::new().with_overwrite_file(overwrite_file != 0); + MiniExcel::merge_same_cells(source_path, destination_path, &options).map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + Ok(RESULT_BATCH) + }) +} + +/// Adds one PNG picture to an existing XLSX workbook. +/// +/// # Safety +/// +/// String and image pointers must be valid for the supplied lengths. `sheet_name` may be null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_add_picture( + path: *const c_char, + sheet_name: *const c_char, + cell_address: *const c_char, + image_data: *const u8, + image_length: usize, + width_px: u32, + height_px: u32, + anchor_type: u8, + location_x: i32, + location_y: i32, +) -> i32 { + ffi_result(|| { + if path.is_null() || cell_address.is_null() || image_data.is_null() { + set_last_error("path, cell_address, and image_data are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + if image_length == 0 || width_px == 0 || height_px == 0 { + set_last_error("image data, width, and height must be non-zero"); + return Err(ERROR_INVALID_ARGUMENT); + } + let path = unsafe { read_utf8(path) }?; + let sheet_name = if sheet_name.is_null() { + None + } else { + let value = unsafe { read_utf8(sheet_name) }?; + (!value.is_empty()).then_some(value) + }; + let cell_address = unsafe { read_utf8(cell_address) }?; + let image = unsafe { std::slice::from_raw_parts(image_data, image_length) }; + add_png_picture( + path, + sheet_name, + cell_address, + image, + width_px, + height_px, + anchor_type, + location_x, + location_y, + )?; + Ok(RESULT_BATCH) + }) +} + +/// Atomically renames a worksheet in an existing XLSX workbook. +/// +/// # Safety +/// +/// All string pointers must be non-null, valid, null-terminated UTF-8 for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_rename_sheet( + path: *const c_char, + sheet_name: *const c_char, + new_sheet_name: *const c_char, +) -> i32 { + ffi_result(|| { + if path.is_null() || sheet_name.is_null() || new_sheet_name.is_null() { + set_last_error("path, sheet_name, and new_sheet_name are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + let path = unsafe { read_utf8(path) }?; + let sheet_name = unsafe { read_utf8(sheet_name) }?; + let new_sheet_name = unsafe { read_utf8(new_sheet_name) }?; + MiniExcel::rename_sheet(path, sheet_name, new_sheet_name).map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + Ok(RESULT_BATCH) + }) +} + +/// Atomically moves a worksheet to a zero-based index. +/// +/// # Safety +/// +/// Both string pointers must be non-null, valid, null-terminated UTF-8 for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_reorder_sheet( + path: *const c_char, + sheet_name: *const c_char, + new_sheet_index: i32, +) -> i32 { + ffi_result(|| { + if path.is_null() || sheet_name.is_null() { + set_last_error("path and sheet_name are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + let path = unsafe { read_utf8(path) }?; + let sheet_name = unsafe { read_utf8(sheet_name) }?; + MiniExcel::reorder_sheet(path, sheet_name, new_sheet_index).map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + Ok(RESULT_BATCH) + }) +} + +/// Atomically changes a worksheet visibility state. +/// +/// # Safety +/// +/// Both string pointers must be non-null, valid, null-terminated UTF-8 for the duration of the call. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_set_sheet_visibility( + path: *const c_char, + sheet_name: *const c_char, + visibility: u8, +) -> i32 { + ffi_result(|| { + if path.is_null() || sheet_name.is_null() { + set_last_error("path and sheet_name are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + let visibility = match visibility { + 0 => SheetVisibility::Visible, + 1 => SheetVisibility::Hidden, + 2 => SheetVisibility::VeryHidden, + _ => { + set_last_error("visibility is not supported"); + return Err(ERROR_INVALID_ARGUMENT); + } + }; + let path = unsafe { read_utf8(path) }?; + let sheet_name = unsafe { read_utf8(sheet_name) }?; + MiniExcel::set_sheet_visibility(path, sheet_name, visibility).map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + Ok(RESULT_BATCH) + }) +} + +/// Releases a buffer returned by a metadata operation. +/// +/// # Safety +/// +/// `handle` must be null or a handle returned by this library that has not already been closed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_buffer_close(handle: *mut BufferHandle) { + if !handle.is_null() { + let _ = catch_unwind(AssertUnwindSafe(|| drop(unsafe { Box::from_raw(handle) }))); + } +} + +/// Returns the last error recorded on the current native thread. +/// +/// # Safety +/// +/// `out_length` may be null; otherwise it must be writable. The returned data remains valid until +/// the next MiniExcel FFI error on this thread. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn miniexcel_last_error(out_length: *mut usize) -> *const u8 { + LAST_ERROR.with(|error| { + let error = error.borrow(); + if !out_length.is_null() { + unsafe { ptr::write(out_length, error.len()) }; + } + error.as_ptr() + }) +} + +fn ffi_result(operation: impl FnOnce() -> Result) -> i32 { + match catch_unwind(AssertUnwindSafe(operation)) { + Ok(Ok(result)) => result, + Ok(Err(code)) => code, + Err(_) => { + set_last_error("Rust panic crossed the MiniExcel FFI boundary"); + ERROR_PANIC + } + } +} + +unsafe fn read_utf8<'a>(value: *const c_char) -> Result<&'a str, i32> { + unsafe { CStr::from_ptr(value) }.to_str().map_err(|error| { + set_last_error(error.to_string()); + ERROR_INVALID_ARGUMENT + }) +} + +unsafe fn open_query( + arguments: QueryOpenOptions, + out_handle: *mut *mut QueryHandle, +) -> Result { + let QueryOpenOptions { + path, + use_header_row, + sheet_name, + start_cell, + end_cell, + ignore_empty_rows, + fill_merged_cells, + trim_headers, + enable_shared_string_cache, + shared_string_cache_size, + shared_string_cache_path, + } = arguments; + if path.is_null() || start_cell.is_null() || out_handle.is_null() { + set_last_error("path, start_cell, and out_handle are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + + unsafe { ptr::write(out_handle, ptr::null_mut()) }; + let path = unsafe { read_utf8(path) }?; + let start_cell_text = unsafe { read_utf8(start_cell) }?; + let start_row = cell_row_index(start_cell_text)?; + let start_column = cell_column_index(start_cell_text)?; + let start_cell = CellReference::from_str(start_cell_text).map_err(|error| { + set_last_error(error.to_string()); + ERROR_INVALID_ARGUMENT + })?; + + let mut options = ReadOptions::new() + .with_header_mode(if use_header_row == 0 { HeaderMode::None } else { HeaderMode::FirstRow }) + .with_start_cell(start_cell) + .with_ignore_empty_rows(ignore_empty_rows != 0) + .with_fill_merged_cells(fill_merged_cells != 0) + .with_trim_headers(trim_headers != 0) + .with_shared_string_disk_cache(enable_shared_string_cache != 0) + .with_shared_string_cache_size(shared_string_cache_size); + + let mut end_row = None; + if !end_cell.is_null() { + let end_cell_text = unsafe { read_utf8(end_cell) }?; + end_row = Some(cell_row_index(end_cell_text)?); + let end_cell = CellReference::from_str(end_cell_text).map_err(|error| { + set_last_error(error.to_string()); + ERROR_INVALID_ARGUMENT + })?; + options = options.with_end_cell(end_cell); + } + + let mut selected_sheet_name = None; + if !sheet_name.is_null() { + let sheet_name = unsafe { read_utf8(sheet_name) }?; + if !sheet_name.is_empty() { + selected_sheet_name = Some(sheet_name.to_owned()); + options = options.with_sheet_name(sheet_name); + } + } + + if !shared_string_cache_path.is_null() { + let cache_path = unsafe { read_utf8(shared_string_cache_path) }?; + if !cache_path.is_empty() { + options = options.with_shared_string_cache_path(cache_path); + } + } + + let rows = MiniExcel::query_with_options(path, &options).map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let rows: Box> + Send> = if ignore_empty_rows + != 0 + { + let columns = MiniExcel::get_columns(path, &options).map_err(|error| { + set_last_error(error.to_string()); + ERROR_QUERY + })?; + let (mut pattern, merged_ranges) = worksheet_physical_row_pattern( + path, + selected_sheet_name.as_deref(), + start_row, + end_row, + )?; + if use_header_row != 0 { + if let Some(header_index) = + pattern.iter().position(|action| matches!(action, PhysicalRowAction::Data { .. })) + { + pattern.remove(header_index); + } + } + Box::new(PhysicalRowIterator { + inner: rows, + pattern: pattern.into_iter(), + columns, + start_column, + normalize_merged_cells: fill_merged_cells != 0, + merge_anchor_values: vec![None; merged_ranges.len()], + merged_ranges, + }) + } else { + rows + }; + let handle = Box::new(QueryHandle { rows, frame: Vec::new() }); + unsafe { ptr::write(out_handle, Box::into_raw(handle)) }; + Ok(RESULT_BATCH) +} + +fn set_last_error(message: impl AsRef) { + LAST_ERROR.with(|error| { + let mut error = error.borrow_mut(); + error.clear(); + error.extend_from_slice(message.as_ref().as_bytes()); + }); +} + +fn write_row(frame: &mut Vec, row: &DynamicRow) -> Result<(), i32> { + write_length(frame, row.len())?; + for (name, value) in row { + write_string(frame, name)?; + match value { + CellValue::Empty => frame.push(0), + CellValue::Bool(value) => { + frame.push(1); + frame.push(u8::from(*value)); + } + CellValue::Int(value) => { + frame.push(2); + frame.extend_from_slice(&value.to_le_bytes()); + } + CellValue::Float(value) => { + frame.push(3); + frame.extend_from_slice(&value.to_le_bytes()); + } + CellValue::String(value) => { + frame.push(4); + write_string(frame, value)?; + } + CellValue::Date(value) => { + frame.push(5); + write_string(frame, value.format("%Y-%m-%d").to_string())?; + } + CellValue::Time(value) => { + frame.push(6); + write_string(frame, value.format("%H:%M:%S%.f").to_string())?; + } + CellValue::DateTime(value) => { + frame.push(7); + let value = if value.date() + == NaiveDate::from_ymd_opt(1899, 12, 31).expect("valid Excel epoch date") + { + *value - Duration::days(1) + } else { + *value + }; + write_string(frame, value.format("%Y-%m-%dT%H:%M:%S%.f").to_string())?; + } + CellValue::Duration(value) => { + frame.push(3); + let excel_days = value.num_milliseconds() as f64 / 86_400_000_f64; + frame.extend_from_slice(&excel_days.to_le_bytes()); + } + CellValue::Error(value) => { + frame.push(9); + write_string(frame, value)?; + } + } + } + Ok(()) +} + +fn write_string(frame: &mut Vec, value: impl AsRef) -> Result<(), i32> { let bytes = value.as_ref().as_bytes(); write_length(frame, bytes.len())?; frame.extend_from_slice(bytes); Ok(()) } +fn write_strings(values: Vec) -> Result, i32> { + let mut frame = Vec::new(); + write_length(&mut frame, values.len())?; + for value in values { + write_string(&mut frame, value)?; + } + Ok(frame) +} + +fn write_optional_string(frame: &mut Vec, value: Option<&str>) -> Result<(), i32> { + frame.push(u8::from(value.is_some())); + if let Some(value) = value { + write_string(frame, value)?; + } + Ok(()) +} + +fn write_person(frame: &mut Vec, person: Option<&CommentPerson>) -> Result<(), i32> { + frame.push(u8::from(person.is_some())); + if let Some(person) = person { + write_string(frame, person.id().to_string())?; + write_string(frame, person.display_name())?; + write_optional_string(frame, person.provider_id())?; + } + Ok(()) +} + +fn write_timestamp(frame: &mut Vec, timestamp: Option<&CommentTimestamp>) -> Result<(), i32> { + let value = timestamp.map(|value| match value { + CommentTimestamp::Local(value) => value.format("%Y-%m-%dT%H:%M:%S%.f").to_string(), + CommentTimestamp::Offset(value) => value.to_rfc3339(), + }); + write_optional_string(frame, value.as_deref()) +} + +fn csv_read_options( + use_header_row: u8, + delimiter: u8, + encoding: u8, + read_empty_as_null: u8, + trim_headers: u8, +) -> Result { + if delimiter == 0 { + set_last_error("delimiter must be a single-byte character"); + return Err(ERROR_INVALID_ARGUMENT); + } + let encoding = parse_csv_encoding(encoding)?; + let configuration = CsvConfiguration::new() + .with_delimiter(delimiter) + .with_encoding(encoding) + .with_read_empty_as_null(read_empty_as_null != 0); + Ok(CsvReadOptions::new() + .with_configuration(configuration) + .with_header_mode(if use_header_row == 0 { HeaderMode::None } else { HeaderMode::FirstRow }) + .with_trim_headers(trim_headers != 0)) +} + +fn parse_csv_encoding(encoding: u8) -> Result { + match encoding { + 0 => Ok(CsvEncoding::Utf8), + 1 => Ok(CsvEncoding::Utf16Le), + 2 => Ok(CsvEncoding::Utf16Be), + 3 => Ok(CsvEncoding::Gbk), + 4 => Ok(CsvEncoding::Windows1252), + _ => { + set_last_error("encoding is not supported"); + Err(ERROR_INVALID_ARGUMENT) + } + } +} + +unsafe fn write_csv( + arguments: CsvWriteArguments, + append: bool, + out_row_count: *mut u32, +) -> Result { + let CsvWriteArguments { + path, + data, + data_length, + delimiter, + encoding, + write_bom, + print_header, + overwrite_file, + } = arguments; + if path.is_null() || data.is_null() || out_row_count.is_null() { + set_last_error("path, data, and out_row_count are required"); + return Err(ERROR_INVALID_ARGUMENT); + } + if delimiter == 0 { + set_last_error("delimiter must be a single-byte character"); + return Err(ERROR_INVALID_ARGUMENT); + } + + unsafe { ptr::write(out_row_count, 0) }; + let path = unsafe { read_utf8(path) }?; + let rows = decode_rows(unsafe { std::slice::from_raw_parts(data, data_length) })?; + let configuration = CsvConfiguration::new() + .with_delimiter(delimiter) + .with_encoding(parse_csv_encoding(encoding)?) + .with_write_bom(write_bom != 0); + let options = CsvWriteOptions::new() + .with_configuration(configuration) + .with_print_header(print_header != 0) + .with_overwrite_file(overwrite_file != 0); + let count = if append { + MiniExcel::append_csv(path, &rows, &options) + } else { + MiniExcel::save_csv(path, &rows, &options) + } + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + })?; + let count = u32::try_from(count).map_err(|_| { + set_last_error("row count exceeds the ABI limit"); + ERROR_WRITE + })?; + unsafe { ptr::write(out_row_count, count) }; + Ok(RESULT_BATCH) +} + +fn decode_rows(bytes: &[u8]) -> Result, i32> { + let mut reader = FrameInput::new(bytes); + let rows = read_rows(&mut reader)?; + reader.ensure_complete()?; + Ok(rows) +} + +fn configured_schema(payload: &serde_json::Value) -> Result>, i32> { + let Some(schema) = payload.get("schema") else { + return Ok(None); + }; + let values = + schema.as_array().ok_or_else(|| invalid_write_options("schema must be an array"))?; + values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| invalid_write_options("schema values must be strings")) + }) + .collect::, _>>() + .map(Some) +} + +fn configured_formula_columns(payload: &serde_json::Value) -> Result, i32> { + let Some(columns) = payload.get("formulaColumns") else { + return Ok(Vec::new()); + }; + let values = columns + .as_array() + .ok_or_else(|| invalid_write_options("formulaColumns must be an array"))?; + values + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or_else(|| invalid_write_options("formulaColumns values must be strings")) + }) + .collect() +} + +fn write_configured_workbook( + path: impl AsRef, + rows: &[DynamicRow], + schema: Option<&[String]>, + options: &WriteOptions, +) -> Result<(), i32> { + match schema { + Some(schema) => MiniExcel::save_as_with_schema(path, schema, rows, options), + None => MiniExcel::save_as_with_options(path, rows, options), + } + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_WRITE + }) +} + +fn write_error(error: impl std::fmt::Display) -> i32 { + set_last_error(format!("failed to write output: {error}")); + ERROR_WRITE +} + +#[cfg(windows)] +fn publish_staged_file(source: &Path, destination: &Path) -> Result<(), i32> { + atomicwrites::replace_atomic(source, destination).map_err(write_error) +} + +#[cfg(not(windows))] +fn publish_staged_file(source: &Path, destination: &Path) -> Result<(), i32> { + std::fs::rename(source, destination).map_err(write_error) +} + +fn configured_write_options(payload: &serde_json::Value) -> Result { + let mut options = WriteOptions::new() + .with_sheet_name(json_string(payload, "sheetName", "Sheet1")?) + .with_overwrite_file(json_bool(payload, "overwriteFile", false)?) + .with_print_header(json_bool(payload, "printHeader", true)?) + .with_auto_filter(json_bool(payload, "autoFilter", true)?) + .with_right_to_left(json_bool(payload, "rightToLeft", false)?) + .with_auto_width(json_bool(payload, "autoWidth", false)?) + .with_wrap_cell_contents(json_bool(payload, "wrapCellContents", false)?) + .with_min_width(json_f64(payload, "minWidth", 8.42857143)?) + .with_max_width(json_f64(payload, "maxWidth", 200.0)?) + .with_freeze_row_count( + json_u64(payload, "freezeRowCount", 1)? + .try_into() + .map_err(|_| invalid_write_options("freezeRowCount exceeds UInt32"))?, + ) + .with_freeze_column_count( + json_u64(payload, "freezeColumnCount", 0)? + .try_into() + .map_err(|_| invalid_write_options("freezeColumnCount exceeds UInt16"))?, + ) + .with_horizontal_alignment(parse_horizontal_alignment(json_string( + payload, + "horizontalAlignment", + "left", + )?)?) + .with_vertical_alignment(parse_vertical_alignment(json_string( + payload, + "verticalAlignment", + "bottom", + )?)?) + .with_table_style(match json_string(payload, "tableStyle", "default")?.as_str() { + "none" => TableStyle::None, + "default" => TableStyle::Default, + _ => return Err(invalid_write_options("tableStyle must be none or default")), + }); + let header_style = HeaderStyle::new() + .with_wrap_text(json_bool(payload, "headerWrapText", false)?) + .with_background_color(parse_rgb_color(&json_string( + payload, + "headerBackgroundColor", + "4472C4", + )?)?) + .with_horizontal_alignment(parse_horizontal_alignment(json_string( + payload, + "headerHorizontalAlignment", + "left", + )?)?) + .with_vertical_alignment(parse_vertical_alignment(json_string( + payload, + "headerVerticalAlignment", + "bottom", + )?)?); + options = options.with_header_style(header_style); + for (property, setter) in + [("dateFormat", 0_u8), ("timeFormat", 1), ("dateTimeFormat", 2), ("durationFormat", 3)] + { + if let Some(value) = payload.get(property).and_then(serde_json::Value::as_str) { + options = match setter { + 0 => options.with_date_format(value), + 1 => options.with_time_format(value), + 2 => options.with_datetime_format(value), + _ => options.with_duration_format(value), + }; + } + } + if let Some(values) = payload.get("columnFormats").and_then(serde_json::Value::as_object) { + for (name, value) in values { + options = options.with_column_format( + name, + value + .as_str() + .ok_or_else(|| invalid_write_options("columnFormats values must be strings"))?, + ); + } + } + if let Some(values) = payload.get("columnWidths").and_then(serde_json::Value::as_object) { + for (name, value) in values { + options = options.with_column_width( + name, + value + .as_f64() + .ok_or_else(|| invalid_write_options("columnWidths values must be numbers"))?, + ); + } + } + if let Some(values) = payload.get("hiddenColumns").and_then(serde_json::Value::as_object) { + for (name, value) in values { + options = options.with_column_hidden( + name, + value.as_bool().ok_or_else(|| { + invalid_write_options("hiddenColumns values must be booleans") + })?, + ); + } + } + Ok(options) +} + +fn parse_horizontal_alignment(value: String) -> Result { + match value.as_str() { + "left" => Ok(HorizontalAlignment::Left), + "center" => Ok(HorizontalAlignment::Center), + "right" => Ok(HorizontalAlignment::Right), + _ => Err(invalid_write_options("horizontal alignment must be left, center, or right")), + } +} + +fn parse_vertical_alignment(value: String) -> Result { + match value.as_str() { + "bottom" => Ok(VerticalAlignment::Bottom), + "center" => Ok(VerticalAlignment::Center), + "top" => Ok(VerticalAlignment::Top), + _ => Err(invalid_write_options("vertical alignment must be bottom, center, or top")), + } +} + +fn parse_rgb_color(value: &str) -> Result { + let value = value.strip_prefix('#').unwrap_or(value); + if value.len() != 6 { + return Err(invalid_write_options("headerBackgroundColor must be a six-digit RGB value")); + } + let color = u32::from_str_radix(value, 16) + .map_err(|_| invalid_write_options("headerBackgroundColor is not valid hexadecimal"))?; + Ok(RgbColor::new( + ((color >> 16) & 0xff) as u8, + ((color >> 8) & 0xff) as u8, + (color & 0xff) as u8, + )) +} + +fn json_string(payload: &serde_json::Value, name: &str, default: &str) -> Result { + match payload.get(name) { + None => Ok(default.to_owned()), + Some(value) => value + .as_str() + .map(str::to_owned) + .ok_or_else(|| invalid_write_options(&format!("{name} must be a string"))), + } +} + +fn json_bool(payload: &serde_json::Value, name: &str, default: bool) -> Result { + match payload.get(name) { + None => Ok(default), + Some(value) => value + .as_bool() + .ok_or_else(|| invalid_write_options(&format!("{name} must be a boolean"))), + } +} + +fn json_f64(payload: &serde_json::Value, name: &str, default: f64) -> Result { + match payload.get(name) { + None => Ok(default), + Some(value) => { + value.as_f64().ok_or_else(|| invalid_write_options(&format!("{name} must be a number"))) + } + } +} + +fn json_u64(payload: &serde_json::Value, name: &str, default: u64) -> Result { + match payload.get(name) { + None => Ok(default), + Some(value) => value.as_u64().ok_or_else(|| { + invalid_write_options(&format!("{name} must be a non-negative integer")) + }), + } +} + +fn invalid_write_options(message: &str) -> i32 { + set_last_error(format!("invalid write options: {message}")); + ERROR_INVALID_ARGUMENT +} + +fn declared_sheet_dimensions(path: &str) -> Result, i32> { + let file = File::open(path).map_err(metadata_error)?; + let mut archive = ZipArchive::new(file).map_err(metadata_error)?; + let workbook = read_zip_entry(&mut archive, "xl/workbook.xml")?; + let relationships = read_zip_entry(&mut archive, "xl/_rels/workbook.xml.rels")?; + let sheet_relationship_ids = workbook_sheet_relationships(&workbook)?; + let relationship_targets = workbook_relationship_targets(&relationships)?; + let mut dimensions = Vec::with_capacity(sheet_relationship_ids.len()); + for relationship_id in sheet_relationship_ids { + let target = relationship_targets.get(&relationship_id).ok_or_else(|| { + set_last_error(format!("workbook relationship '{relationship_id}' was not found")); + ERROR_QUERY + })?; + let worksheet_path = normalize_workbook_target(target); + let worksheet = read_zip_entry(&mut archive, &worksheet_path)?; + dimensions.push(worksheet_declared_dimension(&worksheet)?); + } + Ok(dimensions) +} + +fn worksheet_physical_row_pattern( + path: &str, + sheet_name: Option<&str>, + start_row: usize, + end_row: Option, +) -> Result<(Vec, Vec), i32> { + let file = File::open(path).map_err(metadata_error)?; + let mut archive = ZipArchive::new(file).map_err(metadata_error)?; + let workbook = read_zip_entry(&mut archive, "xl/workbook.xml")?; + let relationships = read_zip_entry(&mut archive, "xl/_rels/workbook.xml.rels")?; + let sheets = workbook_sheets(&workbook)?; + let relationship_id = match sheet_name { + Some(name) => sheets + .iter() + .find(|(sheet, _)| sheet.eq_ignore_ascii_case(name)) + .map(|(_, relationship)| relationship), + None => sheets.first().map(|(_, relationship)| relationship), + } + .ok_or_else(|| { + set_last_error(format!("worksheet '{}' was not found", sheet_name.unwrap_or(""))); + ERROR_QUERY + })?; + let targets = workbook_relationship_targets(&relationships)?; + let target = targets.get(relationship_id).ok_or_else(|| { + set_last_error(format!("workbook relationship '{relationship_id}' was not found")); + ERROR_QUERY + })?; + let worksheet = read_zip_entry(&mut archive, &normalize_workbook_target(target))?; + physical_row_pattern(&worksheet, start_row, end_row) +} + +fn physical_row_pattern( + worksheet: &[u8], + start_row: usize, + end_row: Option, +) -> Result<(Vec, Vec), i32> { + let mut reader = XmlReader::from_reader(worksheet); + let mut pattern = Vec::new(); + let mut current_row: Option<(usize, Vec)> = None; + let mut last_row = 0_usize; + let mut skip_next_data_row = false; + let mut merged_ranges = Vec::new(); + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) if event.local_name().as_ref() == b"row" => { + let row = row_number(&reader, &event, last_row + 1)?; + last_row = row; + current_row = Some((row, Vec::new())); + } + Event::Empty(event) if event.local_name().as_ref() == b"row" => { + let row = row_number(&reader, &event, last_row + 1)?; + last_row = row; + if row >= start_row && end_row.is_none_or(|end| row <= end) { + pattern.push(PhysicalRowAction::Empty); + skip_next_data_row = true; + } + } + Event::Start(event) | Event::Empty(event) if event.local_name().as_ref() == b"c" => { + if let Some((_, columns)) = current_row.as_mut() { + let column = xml_attribute(&reader, &event, b"r")? + .as_deref() + .map(cell_column_index) + .transpose()? + .unwrap_or(columns.len() + 1); + columns.push(column); + } + } + Event::End(event) if event.local_name().as_ref() == b"row" => { + if let Some((row, columns)) = current_row.take() { + if !columns.is_empty() + && row >= start_row + && end_row.is_none_or(|end| row <= end) + { + if skip_next_data_row { + skip_next_data_row = false; + pattern.push(PhysicalRowAction::Skip); + } else { + pattern.push(PhysicalRowAction::Data { row, columns }); + } + } + } + } + Event::Empty(event) if event.local_name().as_ref() == b"mergeCell" => { + if let Some(reference) = xml_attribute(&reader, &event, b"ref")? { + merged_ranges.push(parse_merged_range(&reference)?); + } + } + Event::Eof => break, + _ => {} + } + } + Ok((pattern, merged_ranges)) +} + +fn parse_merged_range(reference: &str) -> Result { + let (start, end) = reference.split_once(':').unwrap_or((reference, reference)); + Ok(MergedRangeInfo { + start_row: cell_row_index(start)?, + start_column: cell_column_index(start)?, + end_row: cell_row_index(end)?, + end_column: cell_column_index(end)?, + }) +} + +fn row_number( + reader: &XmlReader<&[u8]>, + event: &BytesStart<'_>, + fallback: usize, +) -> Result { + match xml_attribute(reader, event, b"r")? { + Some(value) => value.parse().map_err(metadata_error), + None => Ok(fallback), + } +} + +fn cell_row_index(reference: &str) -> Result { + let digits = reference.trim_start_matches(|character: char| character.is_ascii_alphabetic()); + digits.parse().map_err(|error| { + set_last_error(format!("invalid cell row in '{reference}': {error}")); + ERROR_INVALID_ARGUMENT + }) +} + +fn cell_column_index(reference: &str) -> Result { + let mut column = 0_usize; + for character in reference.chars().take_while(char::is_ascii_alphabetic) { + column = column + .checked_mul(26) + .and_then(|value| { + value.checked_add(character.to_ascii_uppercase() as usize - 'A' as usize + 1) + }) + .ok_or_else(|| { + set_last_error(format!("cell column in '{reference}' exceeds the supported range")); + ERROR_INVALID_ARGUMENT + })?; + } + if column == 0 { + set_last_error(format!("invalid cell column in '{reference}'")); + return Err(ERROR_INVALID_ARGUMENT); + } + Ok(column) +} + +fn read_zip_entry( + archive: &mut ZipArchive, + path: &str, +) -> Result, i32> { + let mut entry = archive.by_name(path).map_err(metadata_error)?; + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).map_err(metadata_error)?; + Ok(bytes) +} + +fn workbook_sheet_relationships(workbook: &[u8]) -> Result, i32> { + let mut reader = XmlReader::from_reader(workbook); + let mut relationships = Vec::new(); + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) | Event::Empty(event) + if event.local_name().as_ref() == b"sheet" => + { + if let Some(value) = xml_attribute(&reader, &event, b"r:id")? { + relationships.push(value); + } + } + Event::Eof => break, + _ => {} + } + } + Ok(relationships) +} + +fn workbook_sheets(workbook: &[u8]) -> Result, i32> { + let mut reader = XmlReader::from_reader(workbook); + let mut sheets = Vec::new(); + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) | Event::Empty(event) + if event.local_name().as_ref() == b"sheet" => + { + if let (Some(name), Some(relationship)) = ( + xml_attribute(&reader, &event, b"name")?, + xml_attribute(&reader, &event, b"r:id")?, + ) { + sheets.push((name, relationship)); + } + } + Event::Eof => break, + _ => {} + } + } + Ok(sheets) +} + +fn workbook_relationship_targets(relationships: &[u8]) -> Result, i32> { + let mut reader = XmlReader::from_reader(relationships); + let mut targets = HashMap::new(); + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) | Event::Empty(event) + if event.local_name().as_ref() == b"Relationship" => + { + if let (Some(id), Some(target)) = ( + xml_attribute(&reader, &event, b"Id")?, + xml_attribute(&reader, &event, b"Target")?, + ) { + targets.insert(id, target); + } + } + Event::Eof => break, + _ => {} + } + } + Ok(targets) +} + +fn worksheet_declared_dimension(worksheet: &[u8]) -> Result { + let mut reader = XmlReader::from_reader(worksheet); + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) | Event::Empty(event) + if event.local_name().as_ref() == b"dimension" => + { + let reference = xml_attribute(&reader, &event, b"ref")?; + return Ok(match reference { + Some(reference) => { + let (start, end) = reference + .split_once(':') + .map_or((reference.as_str(), reference.as_str()), |value| value); + (Some(start.to_owned()), Some(end.to_owned())) + } + None => (None, None), + }); + } + Event::Start(event) if event.local_name().as_ref() == b"sheetData" => { + return Ok((None, None)); + } + Event::Eof => return Ok((None, None)), + _ => {} + } + } +} + +fn xml_attribute( + reader: &XmlReader<&[u8]>, + event: &BytesStart<'_>, + name: &[u8], +) -> Result, i32> { + for attribute in event.attributes() { + let attribute = attribute.map_err(metadata_error)?; + if attribute.key.as_ref() == name { + return attribute + .decode_and_unescape_value(reader.decoder()) + .map(|value| Some(value.into_owned())) + .map_err(metadata_error); + } + } + Ok(None) +} + +fn normalize_workbook_target(target: &str) -> String { + let target = target.trim_start_matches('/'); + if target.starts_with("xl/") { target.to_owned() } else { format!("xl/{target}") } +} + +fn metadata_error(error: impl std::fmt::Display) -> i32 { + set_last_error(format!("failed to read XLSX metadata: {error}")); + ERROR_QUERY +} + +#[allow(clippy::too_many_arguments)] +fn add_png_picture( + path: &str, + sheet_name: Option<&str>, + cell_address: &str, + image: &[u8], + width_px: u32, + height_px: u32, + anchor_type: u8, + location_x: i32, + location_y: i32, +) -> Result<(), i32> { + const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n"; + if !image.starts_with(PNG_SIGNATURE) { + set_last_error("only PNG picture data is currently supported"); + return Err(ERROR_INVALID_ARGUMENT); + } + if anchor_type > 2 { + set_last_error("anchor_type must be 0 (one-cell), 1 (absolute), or 2 (two-cell)"); + return Err(ERROR_INVALID_ARGUMENT); + } + let column = cell_column_index(cell_address)? - 1; + let row = cell_row_index(cell_address)? - 1; + let file = File::open(path).map_err(write_error)?; + let mut archive = ZipArchive::new(file).map_err(write_error)?; + let names = archive.file_names().map(str::to_owned).collect::>(); + let workbook = read_zip_entry(&mut archive, "xl/workbook.xml")?; + let workbook_rels = read_zip_entry(&mut archive, "xl/_rels/workbook.xml.rels")?; + let sheets = workbook_sheets(&workbook)?; + let relationship_id = match sheet_name { + Some(name) => sheets + .iter() + .find(|(sheet, _)| sheet.eq_ignore_ascii_case(name)) + .map(|(_, relationship)| relationship), + None => sheets.first().map(|(_, relationship)| relationship), + } + .ok_or_else(|| { + set_last_error(format!("worksheet '{}' was not found", sheet_name.unwrap_or(""))); + ERROR_QUERY + })?; + let targets = workbook_relationship_targets(&workbook_rels)?; + let worksheet_path = + normalize_workbook_target(targets.get(relationship_id).ok_or_else(|| { + set_last_error(format!("workbook relationship '{relationship_id}' was not found")); + ERROR_QUERY + })?); + let worksheet_name = worksheet_path.rsplit('/').next().ok_or_else(|| { + set_last_error("worksheet path has no file name"); + ERROR_QUERY + })?; + let worksheet_rels_path = format!("xl/worksheets/_rels/{worksheet_name}.rels"); + let mut worksheet_xml = String::from_utf8(read_zip_entry(&mut archive, &worksheet_path)?) + .map_err(metadata_error)?; + let mut worksheet_rels = read_optional_zip_entry(&mut archive, &worksheet_rels_path)? + .map(String::from_utf8) + .transpose() + .map_err(metadata_error)? + .unwrap_or_else(empty_relationships_xml); + + let existing_drawing_id = drawing_relationship_id(worksheet_xml.as_bytes())?; + let (drawing_path, drawing_rel_id) = if let Some(id) = existing_drawing_id { + let targets = workbook_relationship_targets(worksheet_rels.as_bytes())?; + let target = targets.get(&id).ok_or_else(|| { + set_last_error(format!("worksheet drawing relationship '{id}' was not found")); + ERROR_QUERY + })?; + (normalize_part_target(&worksheet_path, target), id) + } else { + let index = next_numbered_part(&names, "xl/drawings/drawing", ".xml"); + let drawing_path = format!("xl/drawings/drawing{index}.xml"); + let relationship_id = next_relationship_id(worksheet_rels.as_bytes())?; + worksheet_xml = ensure_relationship_namespace(&worksheet_xml); + worksheet_xml = insert_before( + &worksheet_xml, + "", + &format!(""), + )?; + worksheet_rels = append_relationship( + &worksheet_rels, + &relationship_id, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing", + &format!("../drawings/drawing{index}.xml"), + )?; + (drawing_path, relationship_id) + }; + let _ = drawing_rel_id; + + let drawing_name = drawing_path.rsplit('/').next().expect("drawing file name"); + let drawing_rels_path = format!("xl/drawings/_rels/{drawing_name}.rels"); + let mut drawing_xml = read_optional_zip_entry(&mut archive, &drawing_path)? + .map(String::from_utf8) + .transpose() + .map_err(metadata_error)? + .unwrap_or_else(empty_drawing_xml); + let mut drawing_rels = read_optional_zip_entry(&mut archive, &drawing_rels_path)? + .map(String::from_utf8) + .transpose() + .map_err(metadata_error)? + .unwrap_or_else(empty_relationships_xml); + let image_index = next_numbered_part(&names, "xl/media/image", ".png"); + let image_path = format!("xl/media/image{image_index}.png"); + let image_rel_id = next_relationship_id(drawing_rels.as_bytes())?; + drawing_rels = append_relationship( + &drawing_rels, + &image_rel_id, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image", + &format!("../media/image{image_index}.png"), + )?; + let picture_id = drawing_anchor_count(drawing_xml.as_bytes())? + 2; + let anchor = picture_anchor_xml( + column, + row, + width_px, + height_px, + &image_rel_id, + picture_id, + anchor_type, + location_x, + location_y, + ); + drawing_xml = insert_before(&drawing_xml, "", &anchor)?; + + let mut content_types = String::from_utf8(read_zip_entry(&mut archive, "[Content_Types].xml")?) + .map_err(metadata_error)?; + if !content_types.contains("ContentType=\"image/png\"") { + content_types = insert_before( + &content_types, + "", + "", + )?; + } + let drawing_part = format!("/{drawing_path}"); + if !content_types.contains(&drawing_part) { + content_types = insert_before( + &content_types, + "", + &format!( + "" + ), + )?; + } + + let mut replacements = BTreeMap::new(); + replacements.insert(worksheet_path, worksheet_xml.into_bytes()); + replacements.insert(worksheet_rels_path, worksheet_rels.into_bytes()); + replacements.insert(drawing_path, drawing_xml.into_bytes()); + replacements.insert(drawing_rels_path, drawing_rels.into_bytes()); + replacements.insert("[Content_Types].xml".to_owned(), content_types.into_bytes()); + replacements.insert(image_path, image.to_vec()); + rewrite_package(path, archive, replacements) +} + +fn read_optional_zip_entry( + archive: &mut ZipArchive, + path: &str, +) -> Result>, i32> { + match archive.by_name(path) { + Ok(mut entry) => { + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).map_err(write_error)?; + Ok(Some(bytes)) + } + Err(zip::result::ZipError::FileNotFound) => Ok(None), + Err(error) => Err(write_error(error)), + } +} + +fn rewrite_package( + path: &str, + archive: ZipArchive, + replacements: BTreeMap>, +) -> Result<(), i32> { + write_rewritten_package(path, archive, replacements, true) +} + +fn write_rewritten_package( + path: &str, + mut archive: ZipArchive, + replacements: BTreeMap>, + overwrite: bool, +) -> Result<(), i32> { + let destination = Path::new(path); + if destination.exists() && !overwrite { + return Err(write_error("the destination file already exists")); + } + let parent = destination.parent().unwrap_or_else(|| Path::new(".")); + let mut temporary = tempfile::Builder::new() + .prefix(".miniexcel-package-") + .suffix(".xlsx") + .tempfile_in(parent) + .map_err(write_error)?; + { + let mut writer = ZipWriter::new(temporary.as_file_mut()); + let mut written = std::collections::HashSet::new(); + for index in 0..archive.len() { + let entry = archive.by_index_raw(index).map_err(write_error)?; + let name = entry.name().to_owned(); + if let Some(replacement) = replacements.get(&name) { + writer.start_file(&name, entry.options()).map_err(write_error)?; + writer.write_all(replacement).map_err(write_error)?; + } else { + writer.raw_copy_file(entry).map_err(write_error)?; + } + written.insert(name); + } + for (name, bytes) in &replacements { + if !written.contains(name) { + writer + .start_file( + name, + SimpleFileOptions::default() + .compression_method(CompressionMethod::Deflated), + ) + .map_err(write_error)?; + writer.write_all(bytes).map_err(write_error)?; + } + } + writer.finish().map_err(write_error)?; + } + drop(archive); + temporary.as_file().sync_all().map_err(write_error)?; + if overwrite { + let staging = temporary.into_temp_path(); + publish_staged_file(staging.as_ref(), destination) + } else { + temporary.persist(destination).map_err(|error| write_error(error.error))?; + Ok(()) + } +} + +fn fill_mapped_template( + destination_path: &str, + template_path: &str, + payload: &serde_json::Value, + overwrite: bool, +) -> Result<(), i32> { + let sheet_name = payload + .get("sheetName") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| invalid_write_options("sheetName is required"))?; + let cells = payload + .get("cells") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| invalid_write_options("cells must be an array"))?; + let file = File::open(template_path).map_err(write_error)?; + let mut archive = ZipArchive::new(file).map_err(write_error)?; + let workbook = read_zip_entry(&mut archive, "xl/workbook.xml")?; + let workbook_rels = read_zip_entry(&mut archive, "xl/_rels/workbook.xml.rels")?; + let relationship_id = workbook_sheets(&workbook)? + .into_iter() + .find(|(name, _)| name.eq_ignore_ascii_case(sheet_name)) + .map(|(_, relationship)| relationship) + .ok_or_else(|| { + set_last_error(format!("worksheet '{sheet_name}' was not found")); + ERROR_QUERY + })?; + let targets = workbook_relationship_targets(&workbook_rels)?; + let worksheet_path = + normalize_workbook_target(targets.get(&relationship_id).ok_or_else(|| { + set_last_error(format!("workbook relationship '{relationship_id}' was not found")); + ERROR_QUERY + })?); + let mut worksheet = + String::from_utf8(read_zip_entry(&mut archive, &worksheet_path)?).map_err(write_error)?; + let mut ordered_cells = cells.iter().collect::>(); + ordered_cells.sort_by_key(|cell| { + let address = cell.get("address").and_then(serde_json::Value::as_str).unwrap_or_default(); + ( + cell_row_index(address).unwrap_or(usize::MAX), + cell_column_index(address).unwrap_or(usize::MAX), + ) + }); + for cell in ordered_cells { + let address = cell + .get("address") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| invalid_write_options("each mapped cell requires an address"))?; + let row = cell_row_index(address)?; + let formula = cell.get("formula").and_then(serde_json::Value::as_bool).unwrap_or(false); + let value = cell.get("value").unwrap_or(&serde_json::Value::Null); + worksheet = upsert_worksheet_cell(&worksheet, address, row, value, formula)?; + } + let mut replacements = BTreeMap::new(); + replacements.insert(worksheet_path, worksheet.into_bytes()); + write_rewritten_package(destination_path, archive, replacements, overwrite) +} + +fn upsert_worksheet_cell( + worksheet: &str, + address: &str, + row: usize, + value: &serde_json::Value, + formula: bool, +) -> Result { + if let Some((start, end, style)) = find_cell_element(worksheet, address) { + let cell = mapped_cell_xml(address, value, formula, style.as_deref()); + return Ok(format!("{}{}{}", &worksheet[..start], cell, &worksheet[end..])); + } + let cell = mapped_cell_xml(address, value, formula, None); + if let Some((start, tag_end, end, empty)) = find_row_element(worksheet, row) { + if empty { + let start_tag = worksheet[start..tag_end - 1].trim_end_matches('/'); + let replacement = format!("{start_tag}>{cell}"); + return Ok(format!("{}{}{}", &worksheet[..start], replacement, &worksheet[end..])); + } + let insert_at = worksheet[start..end] + .rfind("") + .map(|index| start + index) + .ok_or_else(|| write_error(format!("row {row} has no closing tag")))?; + return Ok(format!("{}{}{}", &worksheet[..insert_at], cell, &worksheet[insert_at..])); + } + let new_row = format!("{cell}"); + if let Some(insert_at) = worksheet.find("") { + return Ok(format!("{}{}{}", &worksheet[..insert_at], new_row, &worksheet[insert_at..])); + } + if let Some(start) = worksheet.find("') + .ok_or_else(|| write_error("invalid sheetData element"))? + + 1; + if worksheet[start..tag_end].trim_end().ends_with("/>") { + return Ok(format!( + "{}{new_row}{}", + &worksheet[..start], + &worksheet[tag_end..] + )); + } + } + let insert_at = worksheet + .find("") + .ok_or_else(|| write_error("worksheet has no closing element"))?; + let sheet_data = format!("{new_row}"); + Ok(format!("{}{}{}", &worksheet[..insert_at], sheet_data, &worksheet[insert_at..])) +} + +fn find_cell_element(worksheet: &str, address: &str) -> Option<(usize, usize, Option)> { + let attribute = format!("r=\"{address}\""); + let mut offset = 0; + while let Some(relative) = worksheet[offset..].find("' && boundary != b'/' { + offset = start + 2; + continue; + } + let tag_end = start + worksheet[start..].find('>')? + 1; + let tag = &worksheet[start..tag_end]; + if !tag.contains(&attribute) { + offset = tag_end; + continue; + } + let style = xml_tag_attribute(tag, "s"); + let end = if tag.trim_end().ends_with("/>") { + tag_end + } else { + tag_end + worksheet[tag_end..].find("")? + 4 + }; + return Some((start, end, style)); + } + None +} + +fn find_row_element(worksheet: &str, row: usize) -> Option<(usize, usize, usize, bool)> { + let attribute = format!("r=\"{row}\""); + let mut offset = 0; + while let Some(relative) = worksheet[offset..].find("')? + 1; + let tag = &worksheet[start..tag_end]; + if !tag.contains(&attribute) { + offset = tag_end; + continue; + } + let empty = tag.trim_end().ends_with("/>"); + let end = if empty { tag_end } else { tag_end + worksheet[tag_end..].find("")? + 6 }; + return Some((start, tag_end, end, empty)); + } + None +} + +fn xml_tag_attribute(tag: &str, name: &str) -> Option { + let prefix = format!("{name}=\""); + let start = tag.find(&prefix)? + prefix.len(); + let end = start + tag[start..].find('"')?; + Some(tag[start..end].to_owned()) +} + +fn mapped_cell_xml( + address: &str, + value: &serde_json::Value, + formula: bool, + style: Option<&str>, +) -> String { + let style = style.map_or_else(String::new, |value| format!(" s=\"{value}\"")); + if formula { + let formula = value.as_str().unwrap_or_default().trim_start_matches('='); + return format!("{}", xml_escape(formula)); + } + match value { + serde_json::Value::Null => format!(""), + serde_json::Value::Bool(value) => { + format!("{}", u8::from(*value)) + } + serde_json::Value::Number(value) => { + format!("{value}") + } + value => { + let text = value.as_str().map(str::to_owned).unwrap_or_else(|| value.to_string()); + format!( + "{}", + xml_escape(&text) + ) + } + } +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn drawing_relationship_id(worksheet: &[u8]) -> Result, i32> { + let mut reader = XmlReader::from_reader(worksheet); + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) | Event::Empty(event) + if event.local_name().as_ref() == b"drawing" => + { + return xml_attribute(&reader, &event, b"r:id"); + } + Event::Eof => return Ok(None), + _ => {} + } + } +} + +fn drawing_anchor_count(drawing: &[u8]) -> Result { + let mut reader = XmlReader::from_reader(drawing); + let mut count = 0; + loop { + match reader.read_event().map_err(metadata_error)? { + Event::Start(event) + if matches!( + event.local_name().as_ref(), + b"oneCellAnchor" | b"twoCellAnchor" | b"absoluteAnchor" + ) => + { + count += 1 + } + Event::Eof => return Ok(count), + _ => {} + } + } +} + +fn next_relationship_id(relationships: &[u8]) -> Result { + let targets = workbook_relationship_targets(relationships)?; + let mut index = 1; + loop { + let candidate = format!("rId{index}"); + if !targets.contains_key(&candidate) { + return Ok(candidate); + } + index += 1; + } +} + +fn next_numbered_part(names: &[String], prefix: &str, suffix: &str) -> usize { + let mut index = 1; + loop { + let candidate = format!("{prefix}{index}{suffix}"); + if !names.iter().any(|name| name == &candidate) { + return index; + } + index += 1; + } +} + +fn append_relationship( + xml: &str, + id: &str, + relationship_type: &str, + target: &str, +) -> Result { + insert_before( + xml, + "", + &format!(""), + ) +} + +fn insert_before(xml: &str, closing: &str, value: &str) -> Result { + let index = xml.rfind(closing).ok_or_else(|| { + set_last_error(format!("XML closing element '{closing}' was not found")); + ERROR_WRITE + })?; + let mut result = String::with_capacity(xml.len() + value.len()); + result.push_str(&xml[..index]); + result.push_str(value); + result.push_str(&xml[index..]); + Ok(result) +} + +fn ensure_relationship_namespace(xml: &str) -> String { + if xml.contains("xmlns:r=") { + return xml.to_owned(); + } + xml.replacen( + " String { + let mut parts = source_part + .rsplit_once('/') + .map_or(Vec::new(), |(parent, _)| parent.split('/').collect::>()); + for segment in target.split('/') { + match segment { + "" | "." => {} + ".." => { + parts.pop(); + } + value => parts.push(value), + } + } + parts.join("/") +} + +fn empty_relationships_xml() -> String { + "".to_owned() +} + +fn empty_drawing_xml() -> String { + "".to_owned() +} + +#[allow(clippy::too_many_arguments)] +fn picture_anchor_xml( + column: usize, + row: usize, + width_px: u32, + height_px: u32, + relationship_id: &str, + picture_id: usize, + anchor_type: u8, + location_x: i32, + location_y: i32, +) -> String { + let extent = format!( + "", + u64::from(width_px) * 9525, + u64::from(height_px) * 9525 + ); + let position = match anchor_type { + 1 => format!( + "{extent}", + i64::from(location_x) * 9525, + i64::from(location_y) * 9525 + ), + 2 => format!( + "{column}0{row}0{}0{}0", + column + 1, + row + 1 + ), + _ => format!( + "{column}0{row}0{extent}" + ), + }; + let anchor = match anchor_type { + 1 => "absoluteAnchor", + 2 => "twoCellAnchor", + _ => "oneCellAnchor", + }; + let edit_as = if anchor_type == 2 { " editAs=\"twoCell\"" } else { "" }; + format!( + "{position}" + ) +} + +fn decode_sheets(bytes: &[u8]) -> Result)>, i32> { + let mut reader = FrameInput::new(bytes); + let sheet_count = reader.read_length()?; + let mut sheets = Vec::with_capacity(sheet_count); + for _ in 0..sheet_count { + sheets.push((reader.read_string()?, read_rows(&mut reader)?)); + } + reader.ensure_complete()?; + Ok(sheets) +} + +fn read_rows(reader: &mut FrameInput<'_>) -> Result, i32> { + let row_count = reader.read_length()?; + let mut rows = Vec::with_capacity(row_count); + for _ in 0..row_count { + let cell_count = reader.read_length()?; + let mut row = DynamicRow::with_capacity(cell_count); + for _ in 0..cell_count { + let name = reader.read_string()?; + let value = match reader.read_byte()? { + 0 => CellValue::Empty, + 1 => CellValue::Bool(reader.read_byte()? != 0), + 2 => CellValue::Int(reader.read_i64()?), + 3 => CellValue::Float(f64::from_bits(reader.read_u64()?)), + 4 => CellValue::String(reader.read_string()?), + 5 => CellValue::Date( + NaiveDate::parse_from_str(&reader.read_string()?, "%Y-%m-%d") + .map_err(invalid_frame_value)?, + ), + 6 => CellValue::Time( + NaiveTime::parse_from_str(&reader.read_string()?, "%H:%M:%S%.f") + .map_err(invalid_frame_value)?, + ), + 7 => CellValue::DateTime( + NaiveDateTime::parse_from_str(&reader.read_string()?, "%Y-%m-%dT%H:%M:%S%.f") + .map_err(invalid_frame_value)?, + ), + 8 => CellValue::Duration(Duration::milliseconds(reader.read_i64()?)), + 9 => CellValue::Error(reader.read_string()?), + tag => { + set_last_error(format!("input frame contains unsupported value tag {tag}")); + return Err(ERROR_INVALID_ARGUMENT); + } + }; + row.insert(name, value); + } + rows.push(row); + } + Ok(rows) +} + +fn invalid_frame_value(error: chrono::ParseError) -> i32 { + set_last_error(format!("input frame contains an invalid temporal value: {error}")); + ERROR_INVALID_ARGUMENT +} + +fn insert_options( + sheet_name: &str, + print_header: u8, + replace_existing: u8, + remove_supported_relationships: u8, + overwrite_file: bool, +) -> InsertOptions { + InsertOptions::new() + .with_sheet_name(sheet_name) + .with_print_header(print_header != 0) + .with_existing_sheet_policy(if replace_existing == 0 { + ExistingSheetPolicy::Reject + } else { + ExistingSheetPolicy::Replace + }) + .with_target_relationship_policy(if remove_supported_relationships == 0 { + TargetRelationshipPolicy::Reject + } else { + TargetRelationshipPolicy::RemoveSupported + }) + .with_overwrite_file(overwrite_file) +} + +fn write_row_count(count: usize, out_row_count: *mut u32) -> Result { + let count = u32::try_from(count).map_err(|_| { + set_last_error("row count exceeds the ABI limit"); + ERROR_WRITE + })?; + unsafe { ptr::write(out_row_count, count) }; + Ok(RESULT_BATCH) +} + +struct FrameInput<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> FrameInput<'a> { + const fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn read_byte(&mut self) -> Result { + self.ensure_available(1)?; + let value = self.bytes[self.offset]; + self.offset += 1; + Ok(value) + } + + fn read_u32(&mut self) -> Result { + self.ensure_available(4)?; + let mut value = [0_u8; 4]; + value.copy_from_slice(&self.bytes[self.offset..self.offset + 4]); + self.offset += 4; + Ok(u32::from_le_bytes(value)) + } + + fn read_u64(&mut self) -> Result { + self.ensure_available(8)?; + let mut value = [0_u8; 8]; + value.copy_from_slice(&self.bytes[self.offset..self.offset + 8]); + self.offset += 8; + Ok(u64::from_le_bytes(value)) + } + + fn read_i64(&mut self) -> Result { + self.read_u64().map(|value| i64::from_le_bytes(value.to_le_bytes())) + } + + fn read_length(&mut self) -> Result { + self.read_u32().map(|value| value as usize) + } + + fn read_string(&mut self) -> Result { + let length = self.read_length()?; + self.ensure_available(length)?; + let value = std::str::from_utf8(&self.bytes[self.offset..self.offset + length]) + .map_err(|error| { + set_last_error(error.to_string()); + ERROR_INVALID_ARGUMENT + })? + .to_owned(); + self.offset += length; + Ok(value) + } + + fn ensure_complete(&self) -> Result<(), i32> { + if self.offset == self.bytes.len() { + Ok(()) + } else { + set_last_error("input frame contains trailing data"); + Err(ERROR_INVALID_ARGUMENT) + } + } + + fn ensure_available(&self, length: usize) -> Result<(), i32> { + if self.offset <= self.bytes.len().saturating_sub(length) { + Ok(()) + } else { + set_last_error("input frame is truncated"); + Err(ERROR_INVALID_ARGUMENT) + } + } +} + fn write_length(frame: &mut Vec, length: usize) -> Result<(), i32> { let length = u32::try_from(length).map_err(|_| { set_last_error("FFI frame value exceeds the 4 GiB format limit"); @@ -253,3 +3366,46 @@ fn write_length(frame: &mut Vec, length: usize) -> Result<(), i32> { fn write_u32(frame: &mut Vec, value: u32) { frame.extend_from_slice(&value.to_le_bytes()); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reports_the_supported_abi_version() { + assert_eq!(miniexcel_abi_version(), 1); + } + + #[test] + fn rejects_missing_required_query_arguments() { + let result = unsafe { + miniexcel_query_open(ptr::null(), 0, ptr::null(), ptr::null(), 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, 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/scripts/dotnet/Build-Native.ps1 b/scripts/dotnet/Build-Native.ps1 new file mode 100644 index 0000000..432b3b2 --- /dev/null +++ b/scripts/dotnet/Build-Native.ps1 @@ -0,0 +1,81 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateSet( + 'win-x64', + 'win-arm64', + 'linux-x64', + 'linux-arm64', + 'linux-musl-x64', + 'linux-musl-arm64', + 'osx-x64', + 'osx-arm64' + )] + [string] $Rid, + + [switch] $UseZig, + + [string] $Toolchain +) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + +$targets = @{ + 'win-x64' = @{ Triple = 'x86_64-pc-windows-msvc'; File = 'miniexcel_ffi.dll' } + 'win-arm64' = @{ Triple = 'aarch64-pc-windows-msvc'; File = 'miniexcel_ffi.dll' } + 'linux-x64' = @{ Triple = 'x86_64-unknown-linux-gnu'; File = 'libminiexcel_ffi.so' } + 'linux-arm64' = @{ Triple = 'aarch64-unknown-linux-gnu'; File = 'libminiexcel_ffi.so' } + 'linux-musl-x64' = @{ Triple = 'x86_64-unknown-linux-musl'; File = 'libminiexcel_ffi.so' } + 'linux-musl-arm64' = @{ Triple = 'aarch64-unknown-linux-musl'; File = 'libminiexcel_ffi.so' } + 'osx-x64' = @{ Triple = 'x86_64-apple-darwin'; File = 'libminiexcel_ffi.dylib' } + 'osx-arm64' = @{ Triple = 'aarch64-apple-darwin'; File = 'libminiexcel_ffi.dylib' } +} + +$target = $targets[$Rid] +$originalRustFlags = $env:RUSTFLAGS +Push-Location $repositoryRoot +try { + if (-not [string]::IsNullOrWhiteSpace($Toolchain)) { + & rustup target add --toolchain $Toolchain $target.Triple + } + else { + & rustup target add $target.Triple + } + if ($LASTEXITCODE -ne 0) { + throw "Failed to install Rust target $($target.Triple)." + } + + $buildArguments = @( + $(if ($UseZig) { 'zigbuild' } else { 'build' }), + '--release', + '--locked', + '-p', + 'miniexcel-ffi', + '--target', + $target.Triple + ) + + if ($UseZig) { + $env:RUSTFLAGS = "$originalRustFlags -C target-feature=-crt-static".Trim() + } + + if (-not [string]::IsNullOrWhiteSpace($Toolchain)) { + & rustup run $Toolchain cargo @buildArguments + } + else { + & cargo @buildArguments + } + if ($LASTEXITCODE -ne 0) { + throw "Failed to build native library for $Rid." + } + + $source = Join-Path $repositoryRoot "target/$($target.Triple)/release/$($target.File)" + $destinationDirectory = Join-Path $repositoryRoot "target/nuget/native/$Rid" + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null + Copy-Item $source (Join-Path $destinationDirectory $target.File) -Force +} +finally { + $env:RUSTFLAGS = $originalRustFlags + Pop-Location +} diff --git a/scripts/dotnet/Test-Package.ps1 b/scripts/dotnet/Test-Package.ps1 new file mode 100644 index 0000000..87721df --- /dev/null +++ b/scripts/dotnet/Test-Package.ps1 @@ -0,0 +1,72 @@ +[CmdletBinding()] +param( + [ValidateSet('win-x64', 'win-arm64', 'linux-x64', 'linux-arm64', 'linux-musl-x64', 'linux-musl-arm64', 'osx-x64', 'osx-arm64')] + [string] $Rid = 'win-x64', + + [string] $Version = '0.1.0-dev', + + [switch] $SkipNativeBuild +) + +$ErrorActionPreference = 'Stop' +$repositoryRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$packageDirectory = Join-Path $repositoryRoot 'target/nuget/packages' +$packageCache = Join-Path $repositoryRoot 'target/nuget/packages-cache' +$restoreDirectory = Join-Path $repositoryRoot 'target/nuget/restore' +$restoreConfig = Join-Path $restoreDirectory 'nuget.config' +$consumerProject = Join-Path $repositoryRoot 'dotnet/tests/MiniExcel.Rust.PackageTests/MiniExcel.Rust.PackageTests.csproj' + +if (-not $SkipNativeBuild) { + & (Join-Path $PSScriptRoot 'Build-Native.ps1') -Rid $Rid +} + +New-Item -ItemType Directory -Path $packageDirectory -Force | Out-Null +& dotnet pack (Join-Path $repositoryRoot 'dotnet/src/MiniExcel.Rust/MiniExcel.Rust.csproj') ` + -c Release ` + -o $packageDirectory ` + -p:PackageVersion=$Version ` + -p:MiniExcelRustRequireAllNativeAssets=false +if ($LASTEXITCODE -ne 0) { + throw 'NuGet pack failed.' +} + +$package = Join-Path $packageDirectory "MiniExcel.Rust.$Version.nupkg" +Add-Type -AssemblyName System.IO.Compression.FileSystem +$archive = [System.IO.Compression.ZipFile]::OpenRead($package) +try { + $nativeEntry = $archive.Entries | Where-Object { $_.FullName -like "runtimes/$Rid/native/*" } + if ($null -eq $nativeEntry) { + throw "The package does not contain a native asset for $Rid." + } +} +finally { + $archive.Dispose() +} + +$cachedPackage = Join-Path $packageCache "miniexcel.rust/$($Version.ToLowerInvariant())" +if (Test-Path $cachedPackage) { + Remove-Item $cachedPackage -Recurse -Force +} +& dotnet new nugetconfig --output $restoreDirectory --force | Out-Null +if ($LASTEXITCODE -ne 0) { + throw 'NuGet configuration creation failed.' +} +& dotnet nuget add source $packageDirectory --name MiniExcelRustLocal --configfile $restoreConfig | Out-Null +if ($LASTEXITCODE -ne 0) { + throw 'Local NuGet source configuration failed.' +} +& dotnet restore $consumerProject ` + --force ` + --no-cache ` + --packages $packageCache ` + --configfile $restoreConfig ` + -p:MiniExcelRustPackageVersion=$Version +if ($LASTEXITCODE -ne 0) { + throw 'Package consumer restore failed.' +} + +& dotnet run --project $consumerProject -c Release --no-restore ` + -p:MiniExcelRustPackageVersion=$Version +if ($LASTEXITCODE -ne 0) { + throw 'Package consumer smoke test failed.' +} diff --git a/scripts/dotnet/Verify-Package.ps1 b/scripts/dotnet/Verify-Package.ps1 new file mode 100644 index 0000000..41553c6 --- /dev/null +++ b/scripts/dotnet/Verify-Package.ps1 @@ -0,0 +1,80 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $PackagePath, + + [string[]] $ExpectedRids = @( + 'win-x64', + 'win-arm64', + 'linux-x64', + 'linux-arm64', + 'linux-musl-x64', + 'linux-musl-arm64', + 'osx-x64', + 'osx-arm64' + ) +) + +$ErrorActionPreference = 'Stop' +$nativeFiles = @{ + 'win-x64' = 'miniexcel_ffi.dll' + 'win-arm64' = 'miniexcel_ffi.dll' + 'linux-x64' = 'libminiexcel_ffi.so' + 'linux-arm64' = 'libminiexcel_ffi.so' + 'linux-musl-x64' = 'libminiexcel_ffi.so' + 'linux-musl-arm64' = 'libminiexcel_ffi.so' + 'osx-x64' = 'libminiexcel_ffi.dylib' + 'osx-arm64' = 'libminiexcel_ffi.dylib' +} +$expectedNativeEntries = @($ExpectedRids | ForEach-Object { + if (-not $nativeFiles.ContainsKey($_)) { + throw "Unsupported RID '$_'." + } + "runtimes/$_/native/$($nativeFiles[$_])" +}) + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $PackagePath)) +try { + $actualEntries = @($archive.Entries | ForEach-Object FullName) + foreach ($entry in @( + 'lib/net8.0/MiniExcel.Rust.dll', + 'lib/netstandard2.0/MiniExcel.Rust.dll' + ) + $expectedNativeEntries) { + if ($entry -notin $actualEntries) { + throw "The package is missing $entry." + } + } + + $unexpectedNativeEntries = @($actualEntries | Where-Object { + $_ -like 'runtimes/*/native/*' -and $_ -notin $expectedNativeEntries + }) + if ($unexpectedNativeEntries.Count -ne 0) { + throw "The package contains unexpected native assets: $($unexpectedNativeEntries -join ', ')." + } + + $nuspecEntry = $archive.Entries | Where-Object FullName -like '*.nuspec' | Select-Object -First 1 + $reader = [IO.StreamReader]::new($nuspecEntry.Open()) + try { + [xml] $nuspec = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + $namespace = [Xml.XmlNamespaceManager]::new($nuspec.NameTable) + $namespace.AddNamespace('n', $nuspec.DocumentElement.NamespaceURI) + $packageId = $nuspec.SelectSingleNode('/n:package/n:metadata/n:id', $namespace).InnerText + if ($packageId -ne 'MiniExcel.Rust') { + throw "Unexpected package ID '$packageId'." + } + $dependencies = @($nuspec.SelectNodes('//n:dependency[@id="MiniExcel"]', $namespace)) + $invalidDependencies = @($dependencies | Where-Object version -ne '[1.46.0, 2.0.0-0)') + if ($dependencies.Count -ne 2 -or $invalidDependencies.Count -ne 0) { + throw 'Every target framework must depend on MiniExcel [1.46.0, 2.0.0-0).' + } +} +finally { + $archive.Dispose() +} + +Write-Host "Verified MiniExcel.Rust with $($ExpectedRids.Count) native asset(s) and the MiniExcel v1 dependency."