diff --git a/.github/workflows/nuget-release.yml b/.github/workflows/nuget-release.yml index eed813d..ae71b28 100644 --- a/.github/workflows/nuget-release.yml +++ b/.github/workflows/nuget-release.yml @@ -7,9 +7,9 @@ on: workflow_dispatch: inputs: version: - description: NuGet version, for example 0.1.0-preview.1 + description: NuGet version, for example 0.1.0-preview.2 required: true - default: 0.1.0-preview.1 + default: 0.1.0-preview.2 type: string publish: description: Publish to NuGet.org after all package tests pass @@ -227,9 +227,17 @@ jobs: 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 }}' + GH_REPO: ${{ github.repository }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + run: | + arguments=( + '${{ github.ref_name }}' + target/nuget/packages/* + --verify-tag + --generate-notes + --title "MiniExcel.Rust $RELEASE_VERSION" + ) + if [[ "$RELEASE_VERSION" == *-* ]]; then + arguments+=(--prerelease) + fi + gh release create "${arguments[@]}" diff --git a/README.es.md b/README.es.md index b7abad2..99fe9d0 100644 --- a/README.es.md +++ b/README.es.md @@ -52,7 +52,7 @@ v1 `1.46.0`, 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 +dotnet add package MiniExcel.Rust --version 0.1.0-preview.2 ``` ```csharp @@ -115,17 +115,21 @@ Consulta la [matriz de compatibilidad](docs/compatibility.md), el [contrato de a ### Últimos Resultados NuGet La última prueba Windows x64 comparó `MiniExcel 1.46.0`, el wrapper .NET -`MiniExcel.Rust 0.1.0-preview.1` y `MiniExcel Rust 0.4.0` nativo sobre 100.000 filas x 10 columnas, +`MiniExcel.Rust 0.1.0-preview.2` y `MiniExcel Rust 0.4.0` nativo sobre un workbook con dimension +declarada de 100.000 filas x 10 columnas, con cinco procesos nuevos por runtime y escenario. Todos los valores se verificaron antes de medir. | Escenario | Runtime | Mediana | Filas/s | Primera fila | Asignación administrada | Peak working set | | --- | --- | ---: | ---: | ---: | ---: | ---: | -| Cold | MiniExcel | 2.598,50 ms | 38.484 | 1.051,97 ms | 2.541,93 MB | 50,93 MB | -| Cold | MiniExcel.Rust (.NET) | 1.899,42 ms | 52.648 | 450,85 ms | 289,36 MB | 49,64 MB | -| Cold | MiniExcel.Rust | 1.220,82 ms | 81.912 | 537,17 ms | n/a | 4,14 MB | -| Steady | MiniExcel | 5.997,76 ms | 50.019 | 616,40 ms | 7.625,15 MB | 52,80 MB | -| Steady | MiniExcel.Rust (.NET) | 4.202,10 ms | 71.393 | 442,59 ms | 868,05 MB | 48,79 MB | -| Steady | MiniExcel.Rust | 3.344,20 ms | 89.708 | 467,30 ms | n/a | 4,20 MB | +| Cold | MiniExcel | 2.145,90 ms | 46.600 | 38,77 ms | 1.167,07 MB | 51,26 MB | +| Cold | MiniExcel.Rust (.NET) | 1.047,67 ms | 95.450 | 12,05 ms | 107,04 MB | 44,59 MB | +| Cold | MiniExcel.Rust | 855,09 ms | 116.947 | 0,42 ms | n/a | 3,70 MB | +| Steady | MiniExcel | 4.341,55 ms | 69.100 | 3,95 ms | 3.500,58 MB | 54,74 MB | +| Steady | MiniExcel.Rust (.NET) | 2.972,94 ms | 100.910 | 5,50 ms | 321,09 MB | 48,08 MB | +| Steady | MiniExcel.Rust | 2.143,31 ms | 139.970 | 0,41 ms | n/a | 3,79 MB | + +Para una Query completa, preview.2 es 2,05 veces más rápido en Cold y 1,46 veces más rápido en +Steady que MiniExcel v1, con un 90,8 % menos de asignación administrada en ambos escenarios. Los resultados dependen de la máquina. Ejecuta `pwsh ./scripts/compare-nuget-v1-rust.ps1` con workbooks representativos; consulta la [metodología](docs/dotnet-v1-query-benchmark.md). diff --git a/README.fr.md b/README.fr.md index 0e8efd5..c994f3e 100644 --- a/README.fr.md +++ b/README.fr.md @@ -52,7 +52,7 @@ MiniExcel v1 `1.46.0`, 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 +dotnet add package MiniExcel.Rust --version 0.1.0-preview.2 ``` ```csharp @@ -115,17 +115,21 @@ Voir la [matrice de compatibilité](docs/compatibility.md), le [contrat analyse/ ### Derniers Résultats NuGet Le dernier test Windows x64 compare `MiniExcel 1.46.0`, le wrapper .NET -`MiniExcel.Rust 0.1.0-preview.1` et `MiniExcel Rust 0.4.0` natif sur 100 000 lignes x 10 colonnes, +`MiniExcel.Rust 0.1.0-preview.2` et `MiniExcel Rust 0.4.0` natif sur un workbook avec dimension +déclarée de 100 000 lignes x 10 colonnes, avec cinq processus neufs par runtime et scénario. Toutes les valeurs sont vérifiées avant la mesure. | Scénario | Runtime | Temps médian | Lignes/s | Première ligne | Allocation managée | Working set maximal | | --- | --- | ---: | ---: | ---: | ---: | ---: | -| Cold | MiniExcel | 2 598,50 ms | 38 484 | 1 051,97 ms | 2 541,93 MB | 50,93 MB | -| Cold | MiniExcel.Rust (.NET) | 1 899,42 ms | 52 648 | 450,85 ms | 289,36 MB | 49,64 MB | -| Cold | MiniExcel.Rust | 1 220,82 ms | 81 912 | 537,17 ms | n/a | 4,14 MB | -| Steady | MiniExcel | 5 997,76 ms | 50 019 | 616,40 ms | 7 625,15 MB | 52,80 MB | -| Steady | MiniExcel.Rust (.NET) | 4 202,10 ms | 71 393 | 442,59 ms | 868,05 MB | 48,79 MB | -| Steady | MiniExcel.Rust | 3 344,20 ms | 89 708 | 467,30 ms | n/a | 4,20 MB | +| Cold | MiniExcel | 2 145,90 ms | 46 600 | 38,77 ms | 1 167,07 MB | 51,26 MB | +| Cold | MiniExcel.Rust (.NET) | 1 047,67 ms | 95 450 | 12,05 ms | 107,04 MB | 44,59 MB | +| Cold | MiniExcel.Rust | 855,09 ms | 116 947 | 0,42 ms | n/a | 3,70 MB | +| Steady | MiniExcel | 4 341,55 ms | 69 100 | 3,95 ms | 3 500,58 MB | 54,74 MB | +| Steady | MiniExcel.Rust (.NET) | 2 972,94 ms | 100 910 | 5,50 ms | 321,09 MB | 48,08 MB | +| Steady | MiniExcel.Rust | 2 143,31 ms | 139 970 | 0,41 ms | n/a | 3,79 MB | + +Pour une Query complète, preview.2 est 2,05 fois plus rapide en Cold et 1,46 fois plus rapide en +Steady que MiniExcel v1, avec 90,8 % d'allocation managée en moins dans les deux scénarios. Les résultats dépendent de la machine. Relancez `pwsh ./scripts/compare-nuget-v1-rust.ps1` sur des workbooks représentatifs ; voir la [méthodologie](docs/dotnet-v1-query-benchmark.md). diff --git a/README.ja.md b/README.ja.md index 9732e1d..a5e65e5 100644 --- a/README.ja.md +++ b/README.ja.md @@ -52,7 +52,7 @@ v1 `1.46.0` に固定して依存し、その設定型と mapping 型を再利 `MiniExcelRust` の呼び出しを Rust ネイティブライブラリで実行します。 ```bash -dotnet add package MiniExcel.Rust --prerelease +dotnet add package MiniExcel.Rust --version 0.1.0-preview.2 ``` ```csharp @@ -113,18 +113,21 @@ MiniExcel::save_as("book.xlsx", &[row])?; ### 最新 NuGet 結果 -最新の Windows x64 テストでは、100,000 行 x 10 列の workbook で `MiniExcel 1.46.0`、 -.NET wrapper `MiniExcel.Rust 0.1.0-preview.1`、native `MiniExcel Rust 0.4.0` を比較しました。 +最新の Windows x64 テストでは、dimension を宣言した 100,000 行 x 10 列の workbook で +`MiniExcel 1.46.0`、.NET wrapper `MiniExcel.Rust 0.1.0-preview.2`、native `MiniExcel Rust 0.4.0` を比較しました。 runtime とシナリオごとに 5 個の新規 process を使用し、計測前にすべての値を検証しています。 | シナリオ | Runtime | 中央値 | 行/秒 | 最初の行 | Managed allocation | Peak working set | | --- | --- | ---: | ---: | ---: | ---: | ---: | -| Cold | MiniExcel | 2,598.50 ms | 38,484 | 1,051.97 ms | 2,541.93 MB | 50.93 MB | -| Cold | MiniExcel.Rust (.NET) | 1,899.42 ms | 52,648 | 450.85 ms | 289.36 MB | 49.64 MB | -| Cold | MiniExcel.Rust | 1,220.82 ms | 81,912 | 537.17 ms | n/a | 4.14 MB | -| Steady | MiniExcel | 5,997.76 ms | 50,019 | 616.40 ms | 7,625.15 MB | 52.80 MB | -| Steady | MiniExcel.Rust (.NET) | 4,202.10 ms | 71,393 | 442.59 ms | 868.05 MB | 48.79 MB | -| Steady | MiniExcel.Rust | 3,344.20 ms | 89,708 | 467.30 ms | n/a | 4.20 MB | +| Cold | MiniExcel | 2,145.90 ms | 46,600 | 38.77 ms | 1,167.07 MB | 51.26 MB | +| Cold | MiniExcel.Rust (.NET) | 1,047.67 ms | 95,450 | 12.05 ms | 107.04 MB | 44.59 MB | +| Cold | MiniExcel.Rust | 855.09 ms | 116,947 | 0.42 ms | n/a | 3.70 MB | +| Steady | MiniExcel | 4,341.55 ms | 69,100 | 3.95 ms | 3,500.58 MB | 54.74 MB | +| Steady | MiniExcel.Rust (.NET) | 2,972.94 ms | 100,910 | 5.50 ms | 321.09 MB | 48.08 MB | +| Steady | MiniExcel.Rust | 2,143.31 ms | 139,970 | 0.41 ms | n/a | 3.79 MB | + +完全な Query では、preview.2 は MiniExcel v1 より Cold で 2.05 倍、Steady で 1.46 倍高速で、 +managed allocation は両シナリオで 90.8% 少なくなりました。 結果はマシンに依存します。代表的な workbook で `pwsh ./scripts/compare-nuget-v1-rust.ps1` を実行し、 [測定方法](docs/dotnet-v1-query-benchmark.md)を参照してください。 diff --git a/README.md b/README.md index da08377..29ad280 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ v1 `1.46.0`, reuses its configuration and mapping types, and routes calls made through `MiniExcelRust` to the Rust native library. ```bash -dotnet add package MiniExcel.Rust --prerelease +dotnet add package MiniExcel.Rust --version 0.1.0-preview.2 ``` ```csharp @@ -115,18 +115,21 @@ See the [compatibility matrix](docs/compatibility.md), [analytics and RAG contra ### Latest NuGet Results The latest Windows x64 run compared public `MiniExcel 1.46.0`, the -`MiniExcel.Rust 0.1.0-preview.1` .NET wrapper, and native `MiniExcel Rust 0.4.0` over 100,000 rows -x 10 columns, using five fresh processes per runtime and scenario. Every row, column, and -normalized value matched before timing. +`MiniExcel.Rust 0.1.0-preview.2` .NET wrapper, and native `MiniExcel Rust 0.4.0` over a declared-dimension +workbook containing 100,000 rows x 10 columns. Five fresh processes ran per runtime and scenario; +every row, column, and normalized value matched before timing. | Scenario | Runtime | Median elapsed | Rows/s | First row | Managed allocation | Peak working set | | --- | --- | ---: | ---: | ---: | ---: | ---: | -| Cold | MiniExcel | 2,598.50 ms | 38,484 | 1,051.97 ms | 2,541.93 MB | 50.93 MB | -| Cold | MiniExcel.Rust (.NET) | 1,899.42 ms | 52,648 | 450.85 ms | 289.36 MB | 49.64 MB | -| Cold | MiniExcel.Rust | 1,220.82 ms | 81,912 | 537.17 ms | n/a | 4.14 MB | -| Steady | MiniExcel | 5,997.76 ms | 50,019 | 616.40 ms | 7,625.15 MB | 52.80 MB | -| Steady | MiniExcel.Rust (.NET) | 4,202.10 ms | 71,393 | 442.59 ms | 868.05 MB | 48.79 MB | -| Steady | MiniExcel.Rust | 3,344.20 ms | 89,708 | 467.30 ms | n/a | 4.20 MB | +| Cold | MiniExcel | 2,145.90 ms | 46,600 | 38.77 ms | 1,167.07 MB | 51.26 MB | +| Cold | MiniExcel.Rust (.NET) | 1,047.67 ms | 95,450 | 12.05 ms | 107.04 MB | 44.59 MB | +| Cold | MiniExcel.Rust | 855.09 ms | 116,947 | 0.42 ms | n/a | 3.70 MB | +| Steady | MiniExcel | 4,341.55 ms | 69,100 | 3.95 ms | 3,500.58 MB | 54.74 MB | +| Steady | MiniExcel.Rust (.NET) | 2,972.94 ms | 100,910 | 5.50 ms | 321.09 MB | 48.08 MB | +| Steady | MiniExcel.Rust | 2,143.31 ms | 139,970 | 0.41 ms | n/a | 3.79 MB | + +For full Query, preview.2 is 2.05x faster in Cold and 1.46x faster in Steady than MiniExcel v1, +with 90.8% less managed allocation in both scenarios. Results are machine-specific. Run `pwsh ./scripts/compare-nuget-v1-rust.ps1` on representative workbooks; see the [benchmark methodology](docs/dotnet-v1-query-benchmark.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 88d139e..465222d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -51,7 +51,7 @@ cargo add miniexcel 复用其配置和映射类型,并将通过 `MiniExcelRust` 发起的调用交给 Rust 原生库执行。 ```bash -dotnet add package MiniExcel.Rust --prerelease +dotnet add package MiniExcel.Rust --version 0.1.0-preview.2 ``` ```csharp @@ -112,18 +112,21 @@ MiniExcel::save_as("book.xlsx", &[row])?; ### 最新 NuGet 结果 -最新 Windows x64 测试使用 100,000 行 x 10 列工作簿,对比公开的 `MiniExcel 1.46.0`、 -`.NET` 包装器 `MiniExcel.Rust 0.1.0-preview.1` 与原生 `MiniExcel Rust 0.4.0`;每个 runtime、 -每个场景运行 5 个独立进程。计时前已逐行、逐列、逐值验证一致。 +最新 Windows x64 测试使用声明 dimension 的 100,000 行 x 10 列工作簿,对比公开的 +`MiniExcel 1.46.0`、`.NET` 包装器 `MiniExcel.Rust 0.1.0-preview.2` 与原生 +`MiniExcel Rust 0.4.0`;每个 runtime、每个场景运行 5 个独立进程。计时前已逐行、逐列、逐值验证一致。 | 场景 | Runtime | 耗时中位数 | 行/秒 | 首行延迟 | 托管分配 | 峰值工作集 | | --- | --- | ---: | ---: | ---: | ---: | ---: | -| Cold | MiniExcel | 2,598.50 ms | 38,484 | 1,051.97 ms | 2,541.93 MB | 50.93 MB | -| Cold | MiniExcel.Rust (.NET) | 1,899.42 ms | 52,648 | 450.85 ms | 289.36 MB | 49.64 MB | -| Cold | MiniExcel.Rust | 1,220.82 ms | 81,912 | 537.17 ms | 不适用 | 4.14 MB | -| Steady | MiniExcel | 5,997.76 ms | 50,019 | 616.40 ms | 7,625.15 MB | 52.80 MB | -| Steady | MiniExcel.Rust (.NET) | 4,202.10 ms | 71,393 | 442.59 ms | 868.05 MB | 48.79 MB | -| Steady | MiniExcel.Rust | 3,344.20 ms | 89,708 | 467.30 ms | 不适用 | 4.20 MB | +| Cold | MiniExcel | 2,145.90 ms | 46,600 | 38.77 ms | 1,167.07 MB | 51.26 MB | +| Cold | MiniExcel.Rust (.NET) | 1,047.67 ms | 95,450 | 12.05 ms | 107.04 MB | 44.59 MB | +| Cold | MiniExcel.Rust | 855.09 ms | 116,947 | 0.42 ms | 不适用 | 3.70 MB | +| Steady | MiniExcel | 4,341.55 ms | 69,100 | 3.95 ms | 3,500.58 MB | 54.74 MB | +| Steady | MiniExcel.Rust (.NET) | 2,972.94 ms | 100,910 | 5.50 ms | 321.09 MB | 48.08 MB | +| Steady | MiniExcel.Rust | 2,143.31 ms | 139,970 | 0.41 ms | 不适用 | 3.79 MB | + +完整 Query 中,preview.2 在 Cold 与 Steady 场景分别比 MiniExcel v1 快 2.05 倍和 1.46 倍, +两个场景的托管分配都减少 90.8%。 结果与机器和工作簿相关。请用 `pwsh ./scripts/compare-nuget-v1-rust.ps1` 在代表性工作簿上复测; 详见[测试方法](docs/dotnet-v1-query-benchmark.zh-CN.md)。 diff --git a/README.zh-TW.md b/README.zh-TW.md index e003492..c2881b2 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -51,7 +51,7 @@ cargo add miniexcel 重用其設定與 mapping 型別,並將透過 `MiniExcelRust` 發起的呼叫交給 Rust 原生程式庫執行。 ```bash -dotnet add package MiniExcel.Rust --prerelease +dotnet add package MiniExcel.Rust --version 0.1.0-preview.2 ``` ```csharp @@ -112,18 +112,21 @@ MiniExcel::save_as("book.xlsx", &[row])?; ### 最新 NuGet 結果 -最新 Windows x64 測試使用 100,000 列 x 10 欄 workbook,比較公開的 `MiniExcel 1.46.0`、 -`.NET` 包裝器 `MiniExcel.Rust 0.1.0-preview.1` 與原生 `MiniExcel Rust 0.4.0`;每個 runtime、 -每個場景執行 5 個獨立 process。計時前已逐列、逐欄、逐值驗證一致。 +最新 Windows x64 測試使用已宣告 dimension 的 100,000 列 x 10 欄 workbook,比較公開的 +`MiniExcel 1.46.0`、`.NET` 包裝器 `MiniExcel.Rust 0.1.0-preview.2` 與原生 +`MiniExcel Rust 0.4.0`;每個 runtime、每個場景執行 5 個獨立 process。計時前已逐列、逐欄、逐值驗證一致。 | 場景 | Runtime | 耗時中位數 | 列/秒 | 首列延遲 | Managed allocation | Peak working set | | --- | --- | ---: | ---: | ---: | ---: | ---: | -| Cold | MiniExcel | 2,598.50 ms | 38,484 | 1,051.97 ms | 2,541.93 MB | 50.93 MB | -| Cold | MiniExcel.Rust (.NET) | 1,899.42 ms | 52,648 | 450.85 ms | 289.36 MB | 49.64 MB | -| Cold | MiniExcel.Rust | 1,220.82 ms | 81,912 | 537.17 ms | 不適用 | 4.14 MB | -| Steady | MiniExcel | 5,997.76 ms | 50,019 | 616.40 ms | 7,625.15 MB | 52.80 MB | -| Steady | MiniExcel.Rust (.NET) | 4,202.10 ms | 71,393 | 442.59 ms | 868.05 MB | 48.79 MB | -| Steady | MiniExcel.Rust | 3,344.20 ms | 89,708 | 467.30 ms | 不適用 | 4.20 MB | +| Cold | MiniExcel | 2,145.90 ms | 46,600 | 38.77 ms | 1,167.07 MB | 51.26 MB | +| Cold | MiniExcel.Rust (.NET) | 1,047.67 ms | 95,450 | 12.05 ms | 107.04 MB | 44.59 MB | +| Cold | MiniExcel.Rust | 855.09 ms | 116,947 | 0.42 ms | 不適用 | 3.70 MB | +| Steady | MiniExcel | 4,341.55 ms | 69,100 | 3.95 ms | 3,500.58 MB | 54.74 MB | +| Steady | MiniExcel.Rust (.NET) | 2,972.94 ms | 100,910 | 5.50 ms | 321.09 MB | 48.08 MB | +| Steady | MiniExcel.Rust | 2,143.31 ms | 139,970 | 0.41 ms | 不適用 | 3.79 MB | + +完整 Query 中,preview.2 在 Cold 與 Steady 場景分別比 MiniExcel v1 快 2.05 倍與 1.46 倍, +兩個場景的 managed allocation 都減少 90.8%。 結果會受機器與 workbook 影響。請用 `pwsh ./scripts/compare-nuget-v1-rust.ps1` 在代表性 workbook 上重測; 詳見[測試方法](docs/dotnet-v1-query-benchmark.md)。 diff --git a/benchmarks/nuget-v1-query/Program.cs b/benchmarks/nuget-v1-query/Program.cs index ada2c33..372bf4b 100644 --- a/benchmarks/nuget-v1-query/Program.cs +++ b/benchmarks/nuget-v1-query/Program.cs @@ -1,3 +1,5 @@ +using System.Buffers; +using System.Buffers.Binary; using System.Diagnostics; using System.Globalization; using System.IO.Compression; @@ -12,7 +14,8 @@ return args[0].ToLowerInvariant() switch { - "generate" => Generate(args), + "generate" => Generate(args, includeDimension: true), + "generate-no-dimension" => Generate(args, includeDimension: false), "generate-template" => GenerateTemplate(args), "verify" => Verify(args), "fingerprint" => Fingerprint(args), @@ -27,14 +30,14 @@ _ => Usage() }; -static int Generate(string[] arguments) +static int Generate(string[] arguments, bool includeDimension) { if (arguments.Length != 4 || !int.TryParse(arguments[2], out var rowCount) || rowCount < 1 || !int.TryParse(arguments[3], out var columnCount) || columnCount is < 1 or > 26) return Usage(); - CreateWorkbook(Path.GetFullPath(arguments[1]), rowCount, columnCount); + CreateWorkbook(Path.GetFullPath(arguments[1]), rowCount, columnCount, includeDimension); return 0; } @@ -287,7 +290,7 @@ static void AppendRow(IncrementalHash hash, IDictionary row) foreach (var cell in row) { AppendText(hash, cell.Key); - AppendText(hash, Normalize(cell.Value)); + AppendValue(hash, cell.Value); } } @@ -306,11 +309,54 @@ static void AppendRow(IncrementalHash hash, IDictionary row) return (rows, cells, Convert.ToHexString(hash.GetHashAndReset())); } -static void AppendText(IncrementalHash hash, string value) +static void AppendValue(IncrementalHash hash, object? value) { - var bytes = Encoding.UTF8.GetBytes(value); - hash.AppendData(BitConverter.GetBytes(bytes.Length)); - hash.AppendData(bytes); + if (value is null or DBNull) + { + AppendText(hash, "null"); + return; + } + if (value is string text) + { + AppendText(hash, text); + return; + } + if (value is ISpanFormattable spanFormattable) + { + Span characters = stackalloc char[128]; + if (spanFormattable.TryFormat(characters, out var written, default, CultureInfo.InvariantCulture)) + { + AppendCharacters(hash, characters[..written]); + return; + } + } + AppendText(hash, Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty); +} + +static void AppendText(IncrementalHash hash, string value) => AppendCharacters(hash, value.AsSpan()); + +static void AppendCharacters(IncrementalHash hash, ReadOnlySpan value) +{ + var byteCount = Encoding.UTF8.GetByteCount(value); + Span length = stackalloc byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(length, byteCount); + hash.AppendData(length); + + const int StackBufferSize = 256; + byte[]? rented = null; + Span bytes = byteCount <= StackBufferSize + ? stackalloc byte[StackBufferSize] + : rented = ArrayPool.Shared.Rent(byteCount); + try + { + var written = Encoding.UTF8.GetBytes(value, bytes); + hash.AppendData(bytes[..written]); + } + finally + { + if (rented is not null) + ArrayPool.Shared.Return(rented); + } } static string Normalize(object? value) => value switch @@ -320,7 +366,7 @@ static void AppendText(IncrementalHash hash, string value) _ => value.ToString() ?? string.Empty }; -static void CreateWorkbook(string path, int rows, int columns) +static void CreateWorkbook(string path, int rows, int columns, bool includeDimension) { Directory.CreateDirectory(Path.GetDirectoryName(path)!); if (File.Exists(path)) @@ -356,7 +402,11 @@ static void CreateWorkbook(string path, int rows, int columns) var entry = archive.CreateEntry("xl/worksheets/sheet1.xml", CompressionLevel.Fastest); using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(false)); - writer.Write(""); + var endCell = $"{(char)('A' + columns - 1)}{rows}"; + writer.Write(""); + if (includeDimension) + writer.Write($""); + writer.Write(""); for (var row = 1; row <= rows; row++) { writer.Write($""); @@ -388,6 +438,7 @@ static int Usage() { Console.Error.WriteLine("Usage:"); Console.Error.WriteLine(" NuGetV1Query generate "); + Console.Error.WriteLine(" NuGetV1Query generate-no-dimension "); Console.Error.WriteLine(" NuGetV1Query generate-template "); Console.Error.WriteLine(" NuGetV1Query verify "); Console.Error.WriteLine(" NuGetV1Query fingerprint "); diff --git a/docs/compatibility.md b/docs/compatibility.md index 2ed24ad..933caeb 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -54,6 +54,7 @@ The latest `calamine 0.36` and `rust_xlsxwriter 0.97` require Rust 1.88. The MVP | Typed export | `save_as_serialized()` | Uses Serde mapping internally | | Multi-sheet export | `save_as_sheets()` / `save_as_serialized_sheets()` | Preserves input sheet order and returns data-row counts | | `InsertSheet` append/replace | `insert()` / `insert_with_schema()` / `insert_serialized()` / borrowed reader-to-writer variants | Path APIs are atomic; separate borrowed streams require an empty sink and preserve package behavior without atomic commit | +| Deferred cell font-color edits | `MiniExcel::edit_sheet()` / `WorkbookEditor::set_font_color()` / `save()` | Rust-only path API; existing cells in one worksheet, RGB colors, bounded-memory XML rewrite, and atomic commit | | Async Insert producer | `insert_with_schema_async*()` | Optional `async` feature; bounded producer channel with blocking XLSX work on a dedicated thread | | Async path query | `query_async*()` / `query_as_async*()` | Optional `async` feature; bounded dynamic/Serde streams, cooperative cancellation, blocking XLSX workers | | Async dynamic/Serde export | `save_as_with_schema_async*()` / `save_as_serialized_async*()` | Optional `async` feature; explicit or first-row-inferred schema, bounded producer, atomic destination, cooperative cancellation, data-cell progress | @@ -74,7 +75,7 @@ The latest `calamine 0.36` and `rust_xlsxwriter 0.97` require Rust 1.88. The MVP | Caller-owned XLSX input | `visit_*_from_reader()` / metadata `*_from_reader()` | Borrowed `Read + Seek`; synchronous visitor model | | Caller-owned XLSX output | `save_as*_to_writer()` | Borrowed `Write + Send`; dynamic, schema, typed, and multi-sheet | -`MiniExcel` is the only public behavior entry point. Reader, writer, parser, and concrete iterator types are crate-internal. Public supporting types are limited to row/cell values, structured provenance rows, options, errors/results, and Serde date/time helpers. +`MiniExcel` is the primary public behavior entry point. Reader, writer, parser, and concrete iterator types are crate-internal. `WorkbookEditor` is returned only by `MiniExcel::edit_sheet()` and exposes deferred worksheet edits. Other public supporting types are limited to row/cell values, structured provenance rows, options, errors/results, and Serde date/time helpers. ## Compatibility Defaults @@ -125,7 +126,7 @@ Path RAG exports retain parser state, repeated header context, and one output ch The backend makes two sequential, bounded-memory passes over the selected worksheet entry. The first records the used extent and compact merged-cell rectangles. This is required for MiniExcel-compatible stable dynamic schemas when legal files omit ``, to preserve style-only row elements like the .NET reader, and to support opt-in merged-cell filling without expanding ranges into an address map. The second pass emits rows and retains only anchor values for currently active merged ranges. Worksheet XML and prior rows are never retained; memory consists primarily of in-memory or disk-indexed shared strings, styles, merge metadata, parser buffers, the current row, and the bounded channel. -The internal writer assembles a new ZIP package with one or more worksheets. Path saves refuse existing files by default and can explicitly replace them. Path Insert APIs append or replace a worksheet through a validated package rewrite and atomic sibling-file replacement; unchanged ZIP entries and existing worksheet identities are preserved. Separate borrowed Insert APIs accept `Read + Seek` input and an empty `Write + Seek` output, leave both open, and preserve the same package behavior without atomic commit, rollback, or post-write validation. Fallible explicit-schema producers are consumed once through a disk spool and a constant-memory worksheet writer. Generated donor worksheet XML, shared-string conversion, style-ID rebasing, and ZIP insertion use temporary-file streams, so worksheet memory is independent of row count. Path Insert also uses advisory locking and a pre-commit source fingerprint to prevent lost concurrent updates. Template fills rewrite worksheet XML within a copied package; worksheet styles and unrelated ZIP parts are retained. Array expansion shifts row and cell addresses and updates the worksheet dimension. Formula expressions are preserved but not recalculated, and version 1 does not adjust formula references, merged ranges, tables, drawings, or defined names after inserted rows. +The internal writer assembles a new ZIP package with one or more worksheets. Path saves refuse existing files by default and can explicitly replace them. Path Insert APIs append or replace a worksheet through a validated package rewrite and atomic sibling-file replacement; unchanged ZIP entries and existing worksheet identities are preserved. Separate borrowed Insert APIs accept `Read + Seek` input and an empty `Write + Seek` output, leave both open, and preserve the same package behavior without atomic commit, rollback, or post-write validation. Fallible explicit-schema producers are consumed once through a disk spool and a constant-memory worksheet writer. Generated donor worksheet XML, shared-string conversion, style-ID rebasing, and ZIP insertion use temporary-file streams, so worksheet memory is independent of row count. Path Insert also uses advisory locking and a pre-commit source fingerprint to prevent lost concurrent updates. `WorkbookEditor` defers font-color operations until `save()`, then sorts them by cell coordinate, lets the last operation for a cell win, scans the selected worksheet for existing style IDs, deduplicates cloned font/XF records, streams the worksheet rewrite through a temporary file, validates the package, and commits atomically. It currently edits only cells already represented in worksheet XML. Template fills rewrite worksheet XML within a copied package; worksheet styles and unrelated ZIP parts are retained. Array expansion shifts row and cell addresses and updates the worksheet dimension. Formula expressions are preserved but not recalculated, and version 1 does not adjust formula references, merged ranges, tables, drawings, or defined names after inserted rows. ## Test Sources @@ -143,7 +144,7 @@ Rust integration tests reuse the repository's existing files under `tests/data/x - Borrowed dynamic, typed, and structured readers; repeated metadata reads; callback stopping/errors; borrowed dynamic/schema/typed/multi-sheet writers. - Structured formula text, cached values, A1 addresses, style IDs, built-in/custom number formats, ranges, and early iterator drop. -Writer tests generate temporary workbooks through `MiniExcel::save_as*()` and read them back through `MiniExcel::query*()`, covering dynamic and typed values, dates, multiple worksheets, visible/hidden/very-hidden states, active-sheet selection, row counts, empty schemas, default/custom/disabled freeze panes, header/headerless/typed AutoFilter ranges, right-to-left views, bounded fixed AutoWidth output, explicit/hidden column layout, ordinary body wrapping with formatted-value exclusions, body alignment composed with wrapping and number formats, default/custom header styles, default/minimal cell style modes, explicit path overwrite behavior, and worksheet-name validation. Template tests cover scalar and mixed text, native numbers and booleans, XML escaping, formula-injection protection, missing-variable policy, empty and populated arrays, multiple sheets, style retention, path overwrite, and byte workflows. The WASM adapter has native unit tests, while Browser Lab Playwright tests cover generated-workbook rendering, query controls, inclusive end ranges, and desktop/mobile viewports. +Writer tests generate temporary workbooks through `MiniExcel::save_as*()` and read them back through `MiniExcel::query*()`, covering dynamic and typed values, dates, multiple worksheets, visible/hidden/very-hidden states, active-sheet selection, row counts, empty schemas, default/custom/disabled freeze panes, header/headerless/typed AutoFilter ranges, right-to-left views, bounded fixed AutoWidth output, explicit/hidden column layout, ordinary body wrapping with formatted-value exclusions, body alignment composed with wrapping and number formats, default/custom header styles, default/minimal cell style modes, explicit path overwrite behavior, and worksheet-name validation. Template tests cover scalar and mixed text, native numbers and booleans, XML escaping, formula-injection protection, missing-variable policy, empty and populated arrays, multiple sheets, style retention, path overwrite, and byte workflows. The WASM adapter has native unit tests, while Browser Lab Playwright tests cover generated-workbook rendering, query controls, inclusive end ranges, the collapsible and resizable control rail with its validated persisted layout, and desktop/mobile viewports. `TableStyle` controls ordinary cell formats and is not an OOXML table abstraction. Neither mode creates `xl/tables` entries or worksheet `tableParts`. diff --git a/docs/compatibility.zh-CN.md b/docs/compatibility.zh-CN.md index f765eaf..c71abc8 100644 --- a/docs/compatibility.zh-CN.md +++ b/docs/compatibility.zh-CN.md @@ -54,6 +54,7 @@ Rust MVP 在统一的 `MiniExcel` facade 后实现最小但实用的 MiniExcel | 类型化导出 | `save_as_serialized()` | 内部使用 Serde 映射 | | 多工作表导出 | `save_as_sheets()` / `save_as_serialized_sheets()` | 保留输入工作表顺序并返回数据行数 | | `InsertSheet` append/replace | `insert()` / `insert_with_schema()` / `insert_serialized()` / borrowed reader-to-writer variants | Path API 为原子操作;独立 borrowed stream 要求空 sink,并在无原子 commit 的情况下保持相同 package 行为 | +| 延迟 cell 字体颜色编辑 | `MiniExcel::edit_sheet()` / `WorkbookEditor::set_font_color()` / `save()` | Rust-only path API;支持单个 worksheet 中已有 cell、RGB color、有界内存 XML rewrite 与原子 commit | | Async Insert producer | `insert_with_schema_async*()` | 可选 `async` feature;bounded producer channel,XLSX 工作在专用 blocking thread | | Async path query | `query_async*()` / `query_as_async*()` | 可选 `async` feature;bounded 动态/Serde stream、协作式 cancellation、blocking XLSX worker | | Async dynamic/Serde export | `save_as_with_schema_async*()` / `save_as_serialized_async*()` | 可选 `async` feature;显式或首行推断 schema、bounded producer、原子 destination、协作式 cancellation、data-cell progress | @@ -74,7 +75,7 @@ Rust MVP 在统一的 `MiniExcel` facade 后实现最小但实用的 MiniExcel | 调用方持有的 XLSX input | `visit_*_from_reader()` / metadata `*_from_reader()` | 借用 `Read + Seek`;同步 visitor 模型 | | 调用方持有的 XLSX output | `save_as*_to_writer()` | 借用 `Write + Send`;动态、schema、类型化和多工作表 | -`MiniExcel` 是唯一公共行为入口。Reader、writer、parser 和具体迭代器类型均为 crate 内部实现。公共支持类型仅限 row/cell value、结构化 provenance row、option、error/result 和 Serde date/time helper。 +`MiniExcel` 是主要公共行为入口。Reader、writer、parser 和具体迭代器类型均为 crate 内部实现。`WorkbookEditor` 仅由 `MiniExcel::edit_sheet()` 返回,用于延迟 worksheet 编辑。其他公共支持类型仅限 row/cell value、结构化 provenance row、option、error/result 和 Serde date/time helper。 ## 兼容性默认值 @@ -125,7 +126,7 @@ Rust MVP 在统一的 `MiniExcel` facade 后实现最小但实用的 MiniExcel backend 对所选 worksheet entry 执行两次顺序、有界内存扫描。第一次记录使用范围和紧凑 merged-cell 矩形。这是为了在合法文件省略 `` 时保持 MiniExcel 兼容的稳定动态 schema、像 .NET reader 一样保留仅含 style 的 row element,并在不展开地址 map 的情况下支持按需 merged-cell 填充。第二次扫描输出 row,只保留当前活动 merge range 的锚点值。Worksheet XML 和先前 row 永远不会保留;内存主要由内存或磁盘索引的 shared string、style、merge metadata、parser buffer、当前 row 和有界 channel 构成。 -内部 writer 组装包含一个或多个工作表的新 ZIP package。路径保存默认拒绝已有文件,也可显式替换。Path Insert API 通过验证后的 package rewrite 与同目录临时文件原子替换来追加或替换 worksheet;未修改的 ZIP entry 和现有 worksheet identity 会保留。独立 borrowed Insert API 接受 `Read + Seek` input 与空的 `Write + Seek` output,调用后两者保持 open,并在不提供 atomic commit、rollback 或写后验证的情况下保持相同 package 行为。可返回错误的显式 schema producer 只消费一次,经磁盘 spool 与 constant-memory worksheet writer 处理。生成的 donor worksheet XML、shared-string conversion、style-ID rebase 与 ZIP insertion 均使用临时文件 stream,因此 worksheet memory 与 row count 无关。Path Insert 还通过 advisory lock 与 commit 前 source fingerprint 防止并发更新丢失。模板填充会在复制的 package 中重写 worksheet XML;worksheet 样式和无关 ZIP part 会保留。数组展开会移动 row/cell 地址并更新 worksheet dimension。公式表达式会保留但不会重算;版本 1 不会在插行后调整公式引用、merge range、table、drawing 或 defined name。 +内部 writer 组装包含一个或多个工作表的新 ZIP package。路径保存默认拒绝已有文件,也可显式替换。Path Insert API 通过验证后的 package rewrite 与同目录临时文件原子替换来追加或替换 worksheet;未修改的 ZIP entry 和现有 worksheet identity 会保留。独立 borrowed Insert API 接受 `Read + Seek` input 与空的 `Write + Seek` output,调用后两者保持 open,并在不提供 atomic commit、rollback 或写后验证的情况下保持相同 package 行为。可返回错误的显式 schema producer 只消费一次,经磁盘 spool 与 constant-memory worksheet writer 处理。生成的 donor worksheet XML、shared-string conversion、style-ID rebase 与 ZIP insertion 均使用临时文件 stream,因此 worksheet memory 与 row count 无关。Path Insert 还通过 advisory lock 与 commit 前 source fingerprint 防止并发更新丢失。`WorkbookEditor` 会把字体颜色操作延迟到 `save()`,届时按 cell 坐标排序,同一 cell 以最后登记的操作为准;随后扫描所选 worksheet 的已有 style ID、去重克隆 font/XF record、经临时文件流式重写 worksheet、验证 package 并原子提交。当前只编辑 worksheet XML 中已经存在的 cell。模板填充会在复制的 package 中重写 worksheet XML;worksheet 样式和无关 ZIP part 会保留。数组展开会移动 row/cell 地址并更新 worksheet dimension。公式表达式会保留但不会重算;版本 1 不会在插行后调整公式引用、merge range、table、drawing 或 defined name。 ## 测试来源 @@ -143,7 +144,7 @@ Rust integration test 复用仓库 `tests/data/xlsx` 下的现有文件,包括 - 借用动态/类型化/structured reader、重复 metadata 读取、callback 停止/error,以及借用动态/schema/类型化/多表 writer。 - structured formula text、缓存值、A1 地址、style ID、内置/自定义 number format、range 和提前丢弃迭代器。 -Writer test 通过 `MiniExcel::save_as*()` 生成临时 workbook,并使用 `MiniExcel::query*()` 回读,覆盖动态和类型化 value、date、多工作表、visible/hidden/very-hidden 状态、active sheet 选择、行数、空 schema、默认/自定义/禁用冻结窗格、header/headerless/typed AutoFilter 范围、从右到左 view、有界固定 AutoWidth 输出、explicit/hidden column layout、普通 body 换行及 formatted-value 排除、body 对齐与换行/number format 组合、默认/自定义 header style、默认/最小 cell style 模式、显式 path 覆盖行为和 worksheet name 验证。模板测试覆盖标量与混合文本、原生 number/boolean、XML 转义、公式注入防护、缺失变量策略、空数组与非空数组、多工作表、样式保留、path 覆盖和 byte 工作流。WASM adapter 有原生 unit test,Browser Lab Playwright test 则覆盖生成 workbook 的渲染、query 控件、包含端点的结束 range,以及桌面/移动 viewport。 +Writer test 通过 `MiniExcel::save_as*()` 生成临时 workbook,并使用 `MiniExcel::query*()` 回读,覆盖动态和类型化 value、date、多工作表、visible/hidden/very-hidden 状态、active sheet 选择、行数、空 schema、默认/自定义/禁用冻结窗格、header/headerless/typed AutoFilter 范围、从右到左 view、有界固定 AutoWidth 输出、explicit/hidden column layout、普通 body 换行及 formatted-value 排除、body 对齐与换行/number format 组合、默认/自定义 header style、默认/最小 cell style 模式、显式 path 覆盖行为和 worksheet name 验证。模板测试覆盖标量与混合文本、原生 number/boolean、XML 转义、公式注入防护、缺失变量策略、空数组与非空数组、多工作表、样式保留、path 覆盖和 byte 工作流。WASM adapter 有原生 unit test,Browser Lab Playwright test 则覆盖生成 workbook 的渲染、query 控件、包含端点的结束 range、可折叠、可拖曳调整宽度且会校验保存布局的控制栏,以及桌面/移动 viewport。 `TableStyle` 控制普通 cell format,并不是 OOXML table 抽象。两种模式都不会创建 `xl/tables` entry 或 worksheet `tableParts`。 diff --git a/docs/dotnet-v1-query-benchmark.md b/docs/dotnet-v1-query-benchmark.md index 0292562..90c9b43 100644 --- a/docs/dotnet-v1-query-benchmark.md +++ b/docs/dotnet-v1-query-benchmark.md @@ -29,21 +29,25 @@ processes and report query time, first-row latency, managed allocation where app Use `-Rows`, `-Columns`, `-Iterations`, `-Passes`, and `-WarmupPasses` to change the load. The `NuGet Benchmark` GitHub workflow runs the same harness on Windows, Linux, and macOS for x64 and Arm64. Pass `-MiniExcelVersion 1.46.0` to pin a historical baseline for reproducible comparisons. +Generated workbooks declare their worksheet dimension by default. Pass `-OmitDimension` to measure +the fallback path that scans the worksheet before returning its first row. Content hashing uses +stack or pooled UTF-8 buffers so temporary benchmark arrays do not dominate managed allocation. #### Latest NuGet Result -The 2026-09-10 Windows x64 run used 100,000 rows x 10 columns and five fresh processes per runtime -and scenario. `MiniExcel 1.46.0`, `MiniExcel.Rust 0.1.0-preview.1` through .NET, and native +The 2026-09-10 Windows x64 run used a declared-dimension workbook with 100,000 rows x 10 columns +and five fresh processes per runtime and scenario. `MiniExcel 1.46.0`, +`MiniExcel.Rust 0.1.0-preview.2` through .NET, and native `MiniExcel Rust 0.4.0` matched every normalized value. | Scenario | Runtime | Median elapsed | Rows/s | First row | Managed allocation | Peak working set | | --- | --- | ---: | ---: | ---: | ---: | ---: | -| Cold | MiniExcel | 2,598.50 ms | 38,484 | 1,051.97 ms | 2,541.93 MB | 50.93 MB | -| Cold | MiniExcel.Rust (.NET) | 1,899.42 ms | 52,648 | 450.85 ms | 289.36 MB | 49.64 MB | -| Cold | MiniExcel.Rust | 1,220.82 ms | 81,912 | 537.17 ms | n/a | 4.14 MB | -| Steady | MiniExcel | 5,997.76 ms | 50,019 | 616.40 ms | 7,625.15 MB | 52.80 MB | -| Steady | MiniExcel.Rust (.NET) | 4,202.10 ms | 71,393 | 442.59 ms | 868.05 MB | 48.79 MB | -| Steady | MiniExcel.Rust | 3,344.20 ms | 89,708 | 467.30 ms | n/a | 4.20 MB | +| Cold | MiniExcel | 2,145.90 ms | 46,600 | 38.77 ms | 1,167.07 MB | 51.26 MB | +| Cold | MiniExcel.Rust (.NET) | 1,047.67 ms | 95,450 | 12.05 ms | 107.04 MB | 44.59 MB | +| Cold | MiniExcel.Rust | 855.09 ms | 116,947 | 0.42 ms | n/a | 3.70 MB | +| Steady | MiniExcel | 4,341.55 ms | 69,100 | 3.95 ms | 3,500.58 MB | 54.74 MB | +| Steady | MiniExcel.Rust (.NET) | 2,972.94 ms | 100,910 | 5.50 ms | 321.09 MB | 48.08 MB | +| Steady | MiniExcel.Rust | 2,143.31 ms | 139,970 | 0.41 ms | n/a | 3.79 MB | ## Fairness Controls diff --git a/docs/dotnet-v1-query-benchmark.zh-CN.md b/docs/dotnet-v1-query-benchmark.zh-CN.md index 52d5a54..21cf1b2 100644 --- a/docs/dotnet-v1-query-benchmark.zh-CN.md +++ b/docs/dotnet-v1-query-benchmark.zh-CN.md @@ -28,21 +28,25 @@ pwsh ./scripts/compare-nuget-v1-rust.ps1 可通过 `-Rows`、`-Columns`、`-Iterations`、`-Passes` 和 `-WarmupPasses` 调整压力。 GitHub 的 `NuGet Benchmark` workflow 会在 Windows、Linux、macOS 的 x64 与 Arm64 环境运行同一套测试。 如需复现历史结果,可传入 `-MiniExcelVersion 1.46.0` 固定基线版本。 +生成的工作簿默认声明 worksheet dimension;可传入 `-OmitDimension` 专门测试首行返回前必须扫描 +worksheet 的 fallback 路径。内容 hash 使用 stack 或 pooled UTF-8 buffer,避免 benchmark 临时数组 +主导托管分配数据。 #### 最新 NuGet 结果 -2026-09-10 的 Windows x64 测试使用 100,000 行 x 10 列工作簿,每个 runtime、每个场景运行 -5 个独立进程。`MiniExcel 1.46.0`、通过 .NET 调用的 `MiniExcel.Rust 0.1.0-preview.1` 与原生 +2026-09-10 的 Windows x64 测试使用声明 dimension 的 100,000 行 x 10 列工作簿,每个 runtime、 +每个场景运行 5 个独立进程。`MiniExcel 1.46.0`、通过 .NET 调用的 +`MiniExcel.Rust 0.1.0-preview.2` 与原生 `MiniExcel Rust 0.4.0` 的所有规范化值完全一致。 | 场景 | Runtime | 耗时中位数 | 行/秒 | 首行延迟 | 托管分配 | 峰值工作集 | | --- | --- | ---: | ---: | ---: | ---: | ---: | -| Cold | MiniExcel | 2,598.50 ms | 38,484 | 1,051.97 ms | 2,541.93 MB | 50.93 MB | -| Cold | MiniExcel.Rust (.NET) | 1,899.42 ms | 52,648 | 450.85 ms | 289.36 MB | 49.64 MB | -| Cold | MiniExcel.Rust | 1,220.82 ms | 81,912 | 537.17 ms | 不适用 | 4.14 MB | -| Steady | MiniExcel | 5,997.76 ms | 50,019 | 616.40 ms | 7,625.15 MB | 52.80 MB | -| Steady | MiniExcel.Rust (.NET) | 4,202.10 ms | 71,393 | 442.59 ms | 868.05 MB | 48.79 MB | -| Steady | MiniExcel.Rust | 3,344.20 ms | 89,708 | 467.30 ms | 不适用 | 4.20 MB | +| Cold | MiniExcel | 2,145.90 ms | 46,600 | 38.77 ms | 1,167.07 MB | 51.26 MB | +| Cold | MiniExcel.Rust (.NET) | 1,047.67 ms | 95,450 | 12.05 ms | 107.04 MB | 44.59 MB | +| Cold | MiniExcel.Rust | 855.09 ms | 116,947 | 0.42 ms | 不适用 | 3.70 MB | +| Steady | MiniExcel | 4,341.55 ms | 69,100 | 3.95 ms | 3,500.58 MB | 54.74 MB | +| Steady | MiniExcel.Rust (.NET) | 2,972.94 ms | 100,910 | 5.50 ms | 321.09 MB | 48.08 MB | +| Steady | MiniExcel.Rust | 2,143.31 ms | 139,970 | 0.41 ms | 不适用 | 3.79 MB | ## 公平性控制 diff --git a/docs/i18n/README.es.md b/docs/i18n/README.es.md index a63f448..46e59da 100644 --- a/docs/i18n/README.es.md +++ b/docs/i18n/README.es.md @@ -52,7 +52,7 @@ v1 `1.46.0`, 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 +dotnet add package MiniExcel.Rust --version 0.1.0-preview.2 ``` ```csharp @@ -293,17 +293,21 @@ Consulta la [matriz de compatibilidad](../compatibility.md), el [contrato de an ### Últimos Resultados NuGet La última prueba Windows x64 comparó `MiniExcel 1.46.0`, el wrapper .NET -`MiniExcel.Rust 0.1.0-preview.1` y `MiniExcel Rust 0.4.0` nativo sobre 100.000 filas x 10 columnas, +`MiniExcel.Rust 0.1.0-preview.2` y `MiniExcel Rust 0.4.0` nativo sobre un workbook con dimension +declarada de 100.000 filas x 10 columnas, con cinco procesos nuevos por runtime y escenario. Todos los valores se verificaron antes de medir. | Escenario | Runtime | Mediana | Filas/s | Primera fila | Asignación administrada | Peak working set | | --- | --- | ---: | ---: | ---: | ---: | ---: | -| Cold | MiniExcel | 2.598,50 ms | 38.484 | 1.051,97 ms | 2.541,93 MB | 50,93 MB | -| Cold | MiniExcel.Rust (.NET) | 1.899,42 ms | 52.648 | 450,85 ms | 289,36 MB | 49,64 MB | -| Cold | MiniExcel.Rust | 1.220,82 ms | 81.912 | 537,17 ms | n/a | 4,14 MB | -| Steady | MiniExcel | 5.997,76 ms | 50.019 | 616,40 ms | 7.625,15 MB | 52,80 MB | -| Steady | MiniExcel.Rust (.NET) | 4.202,10 ms | 71.393 | 442,59 ms | 868,05 MB | 48,79 MB | -| Steady | MiniExcel.Rust | 3.344,20 ms | 89.708 | 467,30 ms | n/a | 4,20 MB | +| Cold | MiniExcel | 2.145,90 ms | 46.600 | 38,77 ms | 1.167,07 MB | 51,26 MB | +| Cold | MiniExcel.Rust (.NET) | 1.047,67 ms | 95.450 | 12,05 ms | 107,04 MB | 44,59 MB | +| Cold | MiniExcel.Rust | 855,09 ms | 116.947 | 0,42 ms | n/a | 3,70 MB | +| Steady | MiniExcel | 4.341,55 ms | 69.100 | 3,95 ms | 3.500,58 MB | 54,74 MB | +| Steady | MiniExcel.Rust (.NET) | 2.972,94 ms | 100.910 | 5,50 ms | 321,09 MB | 48,08 MB | +| Steady | MiniExcel.Rust | 2.143,31 ms | 139.970 | 0,41 ms | n/a | 3,79 MB | + +Para una Query completa, preview.2 es 2,05 veces más rápido en Cold y 1,46 veces más rápido en +Steady que MiniExcel v1, con un 90,8 % menos de asignación administrada en ambos escenarios. Los resultados dependen de la máquina. Ejecuta `pwsh ./scripts/compare-nuget-v1-rust.ps1` con workbooks representativos; consulta la [metodología](../dotnet-v1-query-benchmark.md). diff --git a/docs/i18n/README.fr.md b/docs/i18n/README.fr.md index 46070da..0fdf590 100644 --- a/docs/i18n/README.fr.md +++ b/docs/i18n/README.fr.md @@ -52,7 +52,7 @@ MiniExcel v1 `1.46.0`, 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 +dotnet add package MiniExcel.Rust --version 0.1.0-preview.2 ``` ```csharp @@ -294,17 +294,21 @@ Voir la [matrice de compatibilité](../compatibility.md), le [contrat analyse/RA ### Derniers Résultats NuGet Le dernier test Windows x64 compare `MiniExcel 1.46.0`, le wrapper .NET -`MiniExcel.Rust 0.1.0-preview.1` et `MiniExcel Rust 0.4.0` natif sur 100 000 lignes x 10 colonnes, +`MiniExcel.Rust 0.1.0-preview.2` et `MiniExcel Rust 0.4.0` natif sur un workbook avec dimension +déclarée de 100 000 lignes x 10 colonnes, avec cinq processus neufs par runtime et scénario. Toutes les valeurs sont vérifiées avant la mesure. | Scénario | Runtime | Temps médian | Lignes/s | Première ligne | Allocation managée | Working set maximal | | --- | --- | ---: | ---: | ---: | ---: | ---: | -| Cold | MiniExcel | 2 598,50 ms | 38 484 | 1 051,97 ms | 2 541,93 MB | 50,93 MB | -| Cold | MiniExcel.Rust (.NET) | 1 899,42 ms | 52 648 | 450,85 ms | 289,36 MB | 49,64 MB | -| Cold | MiniExcel.Rust | 1 220,82 ms | 81 912 | 537,17 ms | n/a | 4,14 MB | -| Steady | MiniExcel | 5 997,76 ms | 50 019 | 616,40 ms | 7 625,15 MB | 52,80 MB | -| Steady | MiniExcel.Rust (.NET) | 4 202,10 ms | 71 393 | 442,59 ms | 868,05 MB | 48,79 MB | -| Steady | MiniExcel.Rust | 3 344,20 ms | 89 708 | 467,30 ms | n/a | 4,20 MB | +| Cold | MiniExcel | 2 145,90 ms | 46 600 | 38,77 ms | 1 167,07 MB | 51,26 MB | +| Cold | MiniExcel.Rust (.NET) | 1 047,67 ms | 95 450 | 12,05 ms | 107,04 MB | 44,59 MB | +| Cold | MiniExcel.Rust | 855,09 ms | 116 947 | 0,42 ms | n/a | 3,70 MB | +| Steady | MiniExcel | 4 341,55 ms | 69 100 | 3,95 ms | 3 500,58 MB | 54,74 MB | +| Steady | MiniExcel.Rust (.NET) | 2 972,94 ms | 100 910 | 5,50 ms | 321,09 MB | 48,08 MB | +| Steady | MiniExcel.Rust | 2 143,31 ms | 139 970 | 0,41 ms | n/a | 3,79 MB | + +Pour une Query complète, preview.2 est 2,05 fois plus rapide en Cold et 1,46 fois plus rapide en +Steady que MiniExcel v1, avec 90,8 % d'allocation managée en moins dans les deux scénarios. Les résultats dépendent de la machine. Relancez `pwsh ./scripts/compare-nuget-v1-rust.ps1` sur des workbooks représentatifs ; voir la [méthodologie](../dotnet-v1-query-benchmark.md). diff --git a/docs/i18n/README.ja.md b/docs/i18n/README.ja.md index 43940da..8dce38c 100644 --- a/docs/i18n/README.ja.md +++ b/docs/i18n/README.ja.md @@ -52,7 +52,7 @@ v1 `1.46.0` に固定して依存し、その設定型と mapping 型を再利 `MiniExcelRust` の呼び出しを Rust ネイティブライブラリで実行します。 ```bash -dotnet add package MiniExcel.Rust --prerelease +dotnet add package MiniExcel.Rust --version 0.1.0-preview.2 ``` ```csharp @@ -288,18 +288,21 @@ Workbook editing、Template、formula、formatting に依存する前に、 ### 最新 NuGet 結果 -最新の Windows x64 テストでは、100,000 行 x 10 列の workbook で `MiniExcel 1.46.0`、 -.NET wrapper `MiniExcel.Rust 0.1.0-preview.1`、native `MiniExcel Rust 0.4.0` を比較しました。 +最新の Windows x64 テストでは、dimension を宣言した 100,000 行 x 10 列の workbook で +`MiniExcel 1.46.0`、.NET wrapper `MiniExcel.Rust 0.1.0-preview.2`、native `MiniExcel Rust 0.4.0` を比較しました。 runtime とシナリオごとに 5 個の新規 process を使用し、計測前にすべての値を検証しています。 | シナリオ | Runtime | 中央値 | 行/秒 | 最初の行 | Managed allocation | Peak working set | | --- | --- | ---: | ---: | ---: | ---: | ---: | -| Cold | MiniExcel | 2,598.50 ms | 38,484 | 1,051.97 ms | 2,541.93 MB | 50.93 MB | -| Cold | MiniExcel.Rust (.NET) | 1,899.42 ms | 52,648 | 450.85 ms | 289.36 MB | 49.64 MB | -| Cold | MiniExcel.Rust | 1,220.82 ms | 81,912 | 537.17 ms | n/a | 4.14 MB | -| Steady | MiniExcel | 5,997.76 ms | 50,019 | 616.40 ms | 7,625.15 MB | 52.80 MB | -| Steady | MiniExcel.Rust (.NET) | 4,202.10 ms | 71,393 | 442.59 ms | 868.05 MB | 48.79 MB | -| Steady | MiniExcel.Rust | 3,344.20 ms | 89,708 | 467.30 ms | n/a | 4.20 MB | +| Cold | MiniExcel | 2,145.90 ms | 46,600 | 38.77 ms | 1,167.07 MB | 51.26 MB | +| Cold | MiniExcel.Rust (.NET) | 1,047.67 ms | 95,450 | 12.05 ms | 107.04 MB | 44.59 MB | +| Cold | MiniExcel.Rust | 855.09 ms | 116,947 | 0.42 ms | n/a | 3.70 MB | +| Steady | MiniExcel | 4,341.55 ms | 69,100 | 3.95 ms | 3,500.58 MB | 54.74 MB | +| Steady | MiniExcel.Rust (.NET) | 2,972.94 ms | 100,910 | 5.50 ms | 321.09 MB | 48.08 MB | +| Steady | MiniExcel.Rust | 2,143.31 ms | 139,970 | 0.41 ms | n/a | 3.79 MB | + +完全な Query では、preview.2 は MiniExcel v1 より Cold で 2.05 倍、Steady で 1.46 倍高速で、 +managed allocation は両シナリオで 90.8% 少なくなりました。 結果はマシンに依存します。代表的な workbook で `pwsh ./scripts/compare-nuget-v1-rust.ps1` を実行し、 [測定方法](../dotnet-v1-query-benchmark.md)を参照してください。 diff --git a/docs/i18n/README.zh-CN.md b/docs/i18n/README.zh-CN.md index 5147687..55bf750 100644 --- a/docs/i18n/README.zh-CN.md +++ b/docs/i18n/README.zh-CN.md @@ -51,7 +51,7 @@ cargo add miniexcel 复用其配置和映射类型,并将通过 `MiniExcelRust` 发起的调用交给 Rust 原生库执行。 ```bash -dotnet add package MiniExcel.Rust --prerelease +dotnet add package MiniExcel.Rust --version 0.1.0-preview.2 ``` ```csharp @@ -282,18 +282,21 @@ cargo run -p miniexcel-cli -- rag-export book.xlsx --header --chunk-rows 25 --fo ### 最新 NuGet 结果 -最新 Windows x64 测试使用 100,000 行 x 10 列工作簿,对比公开的 `MiniExcel 1.46.0`、 -`.NET` 包装器 `MiniExcel.Rust 0.1.0-preview.1` 与原生 `MiniExcel Rust 0.4.0`;每个 runtime、 -每个场景运行 5 个独立进程。计时前已逐行、逐列、逐值验证一致。 +最新 Windows x64 测试使用声明 dimension 的 100,000 行 x 10 列工作簿,对比公开的 +`MiniExcel 1.46.0`、`.NET` 包装器 `MiniExcel.Rust 0.1.0-preview.2` 与原生 +`MiniExcel Rust 0.4.0`;每个 runtime、每个场景运行 5 个独立进程。计时前已逐行、逐列、逐值验证一致。 | 场景 | Runtime | 耗时中位数 | 行/秒 | 首行延迟 | 托管分配 | 峰值工作集 | | --- | --- | ---: | ---: | ---: | ---: | ---: | -| Cold | MiniExcel | 2,598.50 ms | 38,484 | 1,051.97 ms | 2,541.93 MB | 50.93 MB | -| Cold | MiniExcel.Rust (.NET) | 1,899.42 ms | 52,648 | 450.85 ms | 289.36 MB | 49.64 MB | -| Cold | MiniExcel.Rust | 1,220.82 ms | 81,912 | 537.17 ms | 不适用 | 4.14 MB | -| Steady | MiniExcel | 5,997.76 ms | 50,019 | 616.40 ms | 7,625.15 MB | 52.80 MB | -| Steady | MiniExcel.Rust (.NET) | 4,202.10 ms | 71,393 | 442.59 ms | 868.05 MB | 48.79 MB | -| Steady | MiniExcel.Rust | 3,344.20 ms | 89,708 | 467.30 ms | 不适用 | 4.20 MB | +| Cold | MiniExcel | 2,145.90 ms | 46,600 | 38.77 ms | 1,167.07 MB | 51.26 MB | +| Cold | MiniExcel.Rust (.NET) | 1,047.67 ms | 95,450 | 12.05 ms | 107.04 MB | 44.59 MB | +| Cold | MiniExcel.Rust | 855.09 ms | 116,947 | 0.42 ms | 不适用 | 3.70 MB | +| Steady | MiniExcel | 4,341.55 ms | 69,100 | 3.95 ms | 3,500.58 MB | 54.74 MB | +| Steady | MiniExcel.Rust (.NET) | 2,972.94 ms | 100,910 | 5.50 ms | 321.09 MB | 48.08 MB | +| Steady | MiniExcel.Rust | 2,143.31 ms | 139,970 | 0.41 ms | 不适用 | 3.79 MB | + +完整 Query 中,preview.2 在 Cold 与 Steady 场景分别比 MiniExcel v1 快 2.05 倍和 1.46 倍, +两个场景的托管分配都减少 90.8%。 结果与机器和工作簿相关。请用 `pwsh ./scripts/compare-nuget-v1-rust.ps1` 在代表性工作簿上复测; 详见[测试方法](../dotnet-v1-query-benchmark.zh-CN.md)。 diff --git a/docs/i18n/README.zh-TW.md b/docs/i18n/README.zh-TW.md index c7cda70..452ceeb 100644 --- a/docs/i18n/README.zh-TW.md +++ b/docs/i18n/README.zh-TW.md @@ -51,7 +51,7 @@ cargo add miniexcel 重用其設定與 mapping 型別,並將透過 `MiniExcelRust` 發起的呼叫交給 Rust 原生程式庫執行。 ```bash -dotnet add package MiniExcel.Rust --prerelease +dotnet add package MiniExcel.Rust --version 0.1.0-preview.2 ``` ```csharp @@ -282,18 +282,21 @@ Template、公式或格式功能前,請先在[相容性矩陣](../compatibilit ### 最新 NuGet 結果 -最新 Windows x64 測試使用 100,000 列 x 10 欄 workbook,比較公開的 `MiniExcel 1.46.0`、 -`.NET` 包裝器 `MiniExcel.Rust 0.1.0-preview.1` 與原生 `MiniExcel Rust 0.4.0`;每個 runtime、 -每個場景執行 5 個獨立 process。計時前已逐列、逐欄、逐值驗證一致。 +最新 Windows x64 測試使用已宣告 dimension 的 100,000 列 x 10 欄 workbook,比較公開的 +`MiniExcel 1.46.0`、`.NET` 包裝器 `MiniExcel.Rust 0.1.0-preview.2` 與原生 +`MiniExcel Rust 0.4.0`;每個 runtime、每個場景執行 5 個獨立 process。計時前已逐列、逐欄、逐值驗證一致。 | 場景 | Runtime | 耗時中位數 | 列/秒 | 首列延遲 | Managed allocation | Peak working set | | --- | --- | ---: | ---: | ---: | ---: | ---: | -| Cold | MiniExcel | 2,598.50 ms | 38,484 | 1,051.97 ms | 2,541.93 MB | 50.93 MB | -| Cold | MiniExcel.Rust (.NET) | 1,899.42 ms | 52,648 | 450.85 ms | 289.36 MB | 49.64 MB | -| Cold | MiniExcel.Rust | 1,220.82 ms | 81,912 | 537.17 ms | 不適用 | 4.14 MB | -| Steady | MiniExcel | 5,997.76 ms | 50,019 | 616.40 ms | 7,625.15 MB | 52.80 MB | -| Steady | MiniExcel.Rust (.NET) | 4,202.10 ms | 71,393 | 442.59 ms | 868.05 MB | 48.79 MB | -| Steady | MiniExcel.Rust | 3,344.20 ms | 89,708 | 467.30 ms | 不適用 | 4.20 MB | +| Cold | MiniExcel | 2,145.90 ms | 46,600 | 38.77 ms | 1,167.07 MB | 51.26 MB | +| Cold | MiniExcel.Rust (.NET) | 1,047.67 ms | 95,450 | 12.05 ms | 107.04 MB | 44.59 MB | +| Cold | MiniExcel.Rust | 855.09 ms | 116,947 | 0.42 ms | 不適用 | 3.70 MB | +| Steady | MiniExcel | 4,341.55 ms | 69,100 | 3.95 ms | 3,500.58 MB | 54.74 MB | +| Steady | MiniExcel.Rust (.NET) | 2,972.94 ms | 100,910 | 5.50 ms | 321.09 MB | 48.08 MB | +| Steady | MiniExcel.Rust | 2,143.31 ms | 139,970 | 0.41 ms | 不適用 | 3.79 MB | + +完整 Query 中,preview.2 在 Cold 與 Steady 場景分別比 MiniExcel v1 快 2.05 倍與 1.46 倍, +兩個場景的 managed allocation 都減少 90.8%。 結果會受機器與 workbook 影響。請用 `pwsh ./scripts/compare-nuget-v1-rust.ps1` 在代表性 workbook 上重測; 詳見[測試方法](../dotnet-v1-query-benchmark.md)。 diff --git a/dotnet/src/MiniExcel.Rust/MiniExcel.Rust.csproj b/dotnet/src/MiniExcel.Rust/MiniExcel.Rust.csproj index 3cc1cd4..79a8570 100644 --- a/dotnet/src/MiniExcel.Rust/MiniExcel.Rust.csproj +++ b/dotnet/src/MiniExcel.Rust/MiniExcel.Rust.csproj @@ -8,9 +8,10 @@ MiniExcelLibs MiniExcel.Rust MiniExcel.Rust - 0.1.0-preview.1 + 0.1.0-preview.2 MiniExcel.Rust MiniExcel-compatible spreadsheet APIs for .NET powered by MiniExcel for Rust. + Improves query streaming latency and allocation by fetching one initial row and reusing managed column names across native batches. Expands comparative NuGet benchmarks and separates declared-dimension from fallback worksheet scans. Mini-Software Mini-Software excel;xlsx;csv;rust;native;streaming;miniexcel diff --git a/dotnet/tests/MiniExcel.Rust.PackageTests/Program.cs b/dotnet/tests/MiniExcel.Rust.PackageTests/Program.cs index eb01100..16dc591 100644 --- a/dotnet/tests/MiniExcel.Rust.PackageTests/Program.cs +++ b/dotnet/tests/MiniExcel.Rust.PackageTests/Program.cs @@ -41,6 +41,23 @@ configuration: readConfiguration).Single(); Require(typed.Name == "Ada" && typed.Score == 42, "MiniExcel v1 attributes were not honored by typed mapping."); + var batchInputPath = Path.Combine(temporaryDirectory, "batch-input.xlsx"); + global::MiniExcelLibs.MiniExcel.SaveAs( + batchInputPath, + Enumerable.Range(1, 130) + .Select(index => new Dictionary + { + ["Name"] = $"Row {index}", + ["Score"] = index + })); + var batchRows = MiniExcelRust.Query(batchInputPath, useHeaderRow: true).ToList(); + Require(batchRows.Count == 130, "Multi-batch query returned an unexpected row count."); + Require(batchRows[64]["Name"]?.ToString() == "Row 65", "Multi-batch query lost row order."); + var firstColumnName = batchRows[0].Keys.First(); + Require( + batchRows.All(batchRow => ReferenceEquals(firstColumnName, batchRow.Keys.First())), + "Multi-batch query did not reuse managed column names."); + var outputPath = Path.Combine(temporaryDirectory, "output.xlsx"); var writeConfiguration = new OpenXmlConfiguration { diff --git a/miniexcel/examples/nuget_method_benchmark.rs b/miniexcel/examples/nuget_method_benchmark.rs index ec1f9a6..5aec7f3 100644 --- a/miniexcel/examples/nuget_method_benchmark.rs +++ b/miniexcel/examples/nuget_method_benchmark.rs @@ -52,7 +52,7 @@ fn main() -> Result<(), Box> { }); for _ in 0..warmups { if matches!(method, "query" | "query-first") { - query(&input_path, method == "query-first", None)?; + query(&input_path, method == "query-first", None, None)?; } else { run_write_method( method, @@ -68,18 +68,21 @@ fn main() -> Result<(), Box> { let mut total_rows = 0_u64; let mut total_cells = 0_u64; let mut first_row_milliseconds = None; - let mut content_hash = None; + let mut content_hasher = matches!(method, "query" | "query-first").then(Sha256::new); for _ in 0..passes { match method { "query" | "query-first" => { - let (result_rows, result_cells, hash, first_row) = - query(&input_path, method == "query-first", Some(started))?; + let (result_rows, result_cells, first_row) = query( + &input_path, + method == "query-first", + Some(started), + content_hasher.as_mut(), + )?; total_rows += result_rows; total_cells += result_cells; if first_row_milliseconds.is_none() { first_row_milliseconds = Some(first_row); } - content_hash = Some(hash); } "create" | "template" => { run_write_method( @@ -96,6 +99,7 @@ fn main() -> Result<(), Box> { } } let elapsed_milliseconds = started.elapsed().as_secs_f64() * 1000.0; + let content_hash = content_hasher.map(|hasher| format!("{:X}", hasher.finalize())); println!( "{}", serde_json::to_string(&BenchmarkResult { @@ -120,11 +124,11 @@ fn query( path: &Path, first_only: bool, started: Option, -) -> Result<(u64, u64, String, f64), Box> { + mut hasher: Option<&mut Sha256>, +) -> Result<(u64, u64, f64), Box> { let mut rows = 0_u64; let mut cells = 0_u64; let mut first_row_milliseconds = 0.0; - let mut hasher = Sha256::new(); for row in MiniExcel::query(path)? { let row = row?; if rows == 0 { @@ -133,15 +137,17 @@ fn query( } rows += 1; cells += row.len() as u64; - for (name, value) in &row { - append_text(&mut hasher, name); - append_text(&mut hasher, &normalize(value)); + if let Some(hasher) = hasher.as_deref_mut() { + for (name, value) in &row { + append_text(hasher, name); + append_text(hasher, &normalize(value)); + } } if first_only { break; } } - Ok((rows, cells, format!("{:X}", hasher.finalize()), first_row_milliseconds)) + Ok((rows, cells, first_row_milliseconds)) } fn run_write_method( @@ -204,4 +210,4 @@ fn usage() -> io::Error { io::ErrorKind::InvalidInput, "usage: nuget_method_benchmark ", ) -} \ No newline at end of file +} diff --git a/miniexcel/src/cell.rs b/miniexcel/src/cell.rs index e9d27e3..fdb5ee3 100644 --- a/miniexcel/src/cell.rs +++ b/miniexcel/src/cell.rs @@ -141,7 +141,7 @@ impl StructuredRow { } } -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct CellReference { row: usize, column: usize, diff --git a/miniexcel/src/facade.rs b/miniexcel/src/facade.rs index bc76e75..c55a276 100644 --- a/miniexcel/src/facade.rs +++ b/miniexcel/src/facade.rs @@ -29,6 +29,18 @@ use crate::{ExistingSheetPolicy, InsertOptions, SheetVisibility, TargetRelations pub struct MiniExcel; impl MiniExcel { + /// Starts a deferred, fluent edit for one worksheet in an existing XLSX workbook. + /// + /// Operations are normalized and ordered only when [`crate::WorkbookEditor::save`] is called. + #[cfg(not(target_arch = "wasm32"))] + #[must_use] + pub fn edit_sheet( + path: impl AsRef, + sheet_name: impl Into, + ) -> crate::WorkbookEditor { + crate::WorkbookEditor::new(path.as_ref().to_owned(), sheet_name.into()) + } + /// Returns worksheet names in workbook order. pub fn get_sheet_names(path: impl AsRef) -> Result> { crate::streaming::sheet_names(path) diff --git a/miniexcel/src/insert/atomic.rs b/miniexcel/src/insert/atomic.rs index 7b1a384..f2afa46 100644 --- a/miniexcel/src/insert/atomic.rs +++ b/miniexcel/src/insert/atomic.rs @@ -13,10 +13,14 @@ use super::package::PackageInventory; use super::rewrite::{ PackageRewriteStage, ReplacementPlan, append_worksheet_to_writer_with_hook, mutate_worksheet_metadata_to_writer_with_hook, plan_replacement, remap_sheet_index, - reorder_worksheet_to_writer_with_hook, replace_worksheet_to_writer_with_hook, + reorder_worksheet_to_writer_with_hook, replace_worksheet_to_writer_with_hook, styles_path, + update_font_colors_to_writer_with_hook, }; use crate::writer::validate_sheet_name; -use crate::{Error, ExistingSheetPolicy, Result, SheetVisibility, TargetRelationshipPolicy}; +use crate::{ + CellReference, Error, ExistingSheetPolicy, Result, RgbColor, SheetVisibility, + TargetRelationshipPolicy, +}; const WORKSHEET_CONTENT_TYPE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"; @@ -513,6 +517,62 @@ pub(crate) fn reorder_sheet_to_path( reorder_sheet_to_path_with_hook(path.as_ref(), sheet_name, new_sheet_index, |_| Ok(())) } +pub(crate) fn update_font_colors_to_path( + path: &Path, + sheet_name: &str, + colors: &std::collections::BTreeMap, +) -> Result<()> { + validate_sheet_name(sheet_name, &std::collections::HashSet::new())?; + if colors.is_empty() { + return Ok(()); + } + let _guard = PathMutationGuard::acquire(path, "worksheet style update")?; + let source_fingerprint = SourceFingerprint::read(path)?; + let source_metadata = fs::metadata(path)?; + let mut source = File::open(path)?; + let inventory = PackageInventory::inspect(&mut source)?; + let target = inventory + .find_sheet(sheet_name) + .cloned() + .ok_or_else(|| Error::sheet_not_found(sheet_name))?; + let styles_path = styles_path(&inventory)?; + let parent = sibling_directory(path); + let mut temporary = + tempfile::Builder::new().prefix(".miniexcel-").suffix(".xlsx.tmp").tempfile_in(parent)?; + + source.rewind()?; + update_font_colors_to_writer_with_hook( + source, + temporary.as_file_mut(), + &target.target, + &styles_path, + colors, + |_| Ok(()), + )?; + temporary.as_file_mut().flush()?; + temporary.as_file().sync_all()?; + validate_rewritten_package(temporary.reopen()?, &target.name)?; + let rewritten = PackageInventory::inspect(temporary.reopen()?)?; + if rewritten.sheets != inventory.sheets + || rewritten.views != inventory.views + || rewritten.defined_names != inventory.defined_names + || rewritten.relationships != inventory.relationships + || rewritten.entry_names != inventory.entry_names + || rewritten.content_types != inventory.content_types + { + return Err(Error::atomic_commit( + "worksheet style update changed unrelated workbook metadata", + )); + } + if SourceFingerprint::read(path)? != source_fingerprint { + return Err(Error::atomic_commit(format!( + "source workbook '{}' changed during worksheet style update", + path.display(), + ))); + } + replace_temporary(temporary, path, source_metadata.permissions()) +} + fn reorder_sheet_to_path_with_hook( path: &Path, sheet_name: &str, diff --git a/miniexcel/src/insert/rewrite.rs b/miniexcel/src/insert/rewrite.rs index a434ed7..27f6fe6 100644 --- a/miniexcel/src/insert/rewrite.rs +++ b/miniexcel/src/insert/rewrite.rs @@ -8,8 +8,10 @@ use zip::{CompressionMethod, ZipArchive, ZipWriter}; use super::donor::DonorWorksheet; use super::package::{DefinedName, PackageInventory, WorkbookSheet, WorksheetAllocation}; -use super::style::rebase_styles; -use crate::{Error, Result, SheetVisibility, TargetRelationshipPolicy}; +use super::style::{ + collect_cell_styles, rebase_styles, rewrite_worksheet_font_colors, update_font_colors, +}; +use crate::{CellReference, Error, Result, RgbColor, SheetVisibility, TargetRelationshipPolicy}; const CONTENT_TYPES_PATH: &str = "[Content_Types].xml"; const WORKBOOK_PATH: &str = "xl/workbook.xml"; @@ -462,6 +464,68 @@ where ) } +pub(super) fn update_font_colors_to_writer_with_hook( + mut source: R, + destination: W, + worksheet_path: &str, + styles_path: &str, + colors: &BTreeMap, + mut checkpoint: F, +) -> Result +where + R: Read + Seek, + W: Write + Seek, + F: FnMut(PackageRewriteStage) -> Result<()>, +{ + source.seek(SeekFrom::Start(0))?; + let (styles_xml, cell_styles) = { + let mut archive = ZipArchive::new(&mut source).map_err(|error| { + Error::insert_package(format!("cannot reopen source workbook: {error}")) + })?; + let styles_xml = read_part(&mut archive, styles_path)?; + let worksheet = archive.by_name(worksheet_path).map_err(|error| { + Error::insert_package(format!("cannot read worksheet '{worksheet_path}': {error}")) + })?; + let cells = colors.keys().copied().collect::>(); + let cell_styles = collect_cell_styles(BufReader::new(worksheet), &cells)?; + (styles_xml, cell_styles) + }; + let (styles_xml, rewritten_styles) = update_font_colors(&styles_xml, &cell_styles, colors)?; + + source.seek(SeekFrom::Start(0))?; + let mut worksheet_xml = tempfile::NamedTempFile::new()?; + { + let mut archive = ZipArchive::new(&mut source).map_err(|error| { + Error::insert_package(format!("cannot reopen source workbook: {error}")) + })?; + let worksheet = archive.by_name(worksheet_path).map_err(|error| { + Error::insert_package(format!("cannot read worksheet '{worksheet_path}': {error}")) + })?; + rewrite_worksheet_font_colors( + BufReader::new(worksheet), + worksheet_xml.as_file_mut(), + &rewritten_styles, + )?; + worksheet_xml.as_file_mut().flush()?; + } + + source.seek(SeekFrom::Start(0))?; + let archive = ZipArchive::new(source).map_err(|error| { + Error::insert_package(format!("cannot reopen source workbook: {error}")) + })?; + let replacements = BTreeMap::from([(styles_path.to_owned(), styles_xml)]); + let mut worksheet_reader = BufReader::new(worksheet_xml.reopen()?); + write_package( + archive, + destination, + &replacements, + &BTreeSet::new(), + Some((worksheet_path, &mut worksheet_reader)), + None, + &mut checkpoint, + ) +} + pub(super) const fn remap_sheet_index( index: usize, source_index: usize, @@ -969,7 +1033,7 @@ fn relationship_part_path(source: &str) -> Result { } } -fn styles_path(inventory: &PackageInventory) -> Result { +pub(super) fn styles_path(inventory: &PackageInventory) -> Result { let mut paths = inventory .relationships .iter() diff --git a/miniexcel/src/insert/style.rs b/miniexcel/src/insert/style.rs index 25d132a..1810cf4 100644 --- a/miniexcel/src/insert/style.rs +++ b/miniexcel/src/insert/style.rs @@ -1,11 +1,11 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::io::{BufRead, BufReader, Cursor, Read, Write}; use quick_xml::events::{BytesEnd, BytesStart, Event}; use quick_xml::{Reader, Writer}; use super::donor::DonorWorksheet; -use crate::{Error, Result}; +use crate::{CellReference, Error, Result, RgbColor}; const FIRST_CUSTOM_NUM_FMT_ID: u32 = 164; const MAX_NUM_FMT_ID: u32 = u16::MAX as u32; @@ -41,6 +41,152 @@ pub(crate) fn rebase_styles( rebase_style_reader(target_styles_xml, &donor.styles.xml, donor.worksheet_reader()?) } +pub(crate) fn collect_cell_styles( + xml: R, + cells: &BTreeSet, +) -> Result> +where + R: BufRead, +{ + let mut reader = Reader::from_reader(xml); + let mut buffer = Vec::new(); + let mut styles = BTreeMap::new(); + loop { + let event = reader + .read_event_into(&mut buffer) + .map_err(|error| Error::insert_package(format!("invalid worksheet XML: {error}")))?; + match event { + Event::Start(cell) | Event::Empty(cell) if local_name(cell.name().as_ref()) == b"c" => { + if let Some(address) = attribute(&cell, b"r")? { + let reference = address.parse::()?; + if cells.contains(&reference) { + styles.insert(reference, numeric_attribute(&cell, b"s")?.unwrap_or(0)); + } + } + } + Event::Eof => break, + _ => {} + } + buffer.clear(); + } + if let Some(cell) = cells.iter().find(|cell| !styles.contains_key(cell)) { + return Err(Error::insert_package(format!("cell '{cell}' does not exist"))); + } + Ok(styles) +} + +pub(crate) fn update_font_colors( + styles_xml: &[u8], + cell_styles: &BTreeMap, + colors: &BTreeMap, +) -> Result<(Vec, BTreeMap)> { + let document = StyleDocument::parse(styles_xml)?; + let fonts = document.nodes(StyleSection::Fonts)?; + let cell_xfs = document.nodes(StyleSection::CellXfs)?; + let mut font_indexes = fonts + .iter() + .enumerate() + .map(|(index, node)| Ok((node.key()?, index as u32))) + .collect::>>()?; + let mut xf_indexes = cell_xfs + .iter() + .enumerate() + .map(|(index, node)| Ok((node.key()?, index as u32))) + .collect::>>()?; + let mut appended_fonts = Vec::new(); + let mut appended_xfs = Vec::new(); + let mut rewritten_cells = BTreeMap::new(); + + for (cell, color) in colors { + let old_xf_id = *cell_styles + .get(cell) + .ok_or_else(|| Error::insert_package(format!("cell '{cell}' has no style")))?; + let old_xf = cell_xfs.get(old_xf_id as usize).ok_or_else(|| { + Error::insert_package(format!("cell '{cell}' references missing style {old_xf_id}")) + })?; + let old_font_id = numeric_attribute(old_xf.root()?, b"fontId")?.unwrap_or(0); + let old_font = fonts.get(old_font_id as usize).ok_or_else(|| { + Error::insert_package(format!( + "style {old_xf_id} references missing font {old_font_id}" + )) + })?; + let new_font = old_font.with_font_color(*color)?; + let font_key = new_font.key()?; + let new_font_id = if let Some(index) = font_indexes.get(&font_key) { + *index + } else { + let index = (fonts.len() + appended_fonts.len()) as u32; + font_indexes.insert(font_key, index); + appended_fonts.push(new_font); + index + }; + let new_xf = old_xf.with_attributes(&[ + (b"fontId", new_font_id.to_string()), + (b"applyFont", "1".to_owned()), + ])?; + let xf_key = new_xf.key()?; + let new_xf_id = if let Some(index) = xf_indexes.get(&xf_key) { + *index + } else { + let index = (cell_xfs.len() + appended_xfs.len()) as u32; + xf_indexes.insert(xf_key, index); + appended_xfs.push(new_xf); + index + }; + rewritten_cells.insert(*cell, new_xf_id); + } + + validate_limits( + document.nodes(StyleSection::NumFmts)?.len(), + fonts.len() + appended_fonts.len(), + document.nodes(StyleSection::Fills)?.len(), + document.nodes(StyleSection::Borders)?.len(), + document.nodes(StyleSection::CellStyleXfs)?.len(), + cell_xfs.len() + appended_xfs.len(), + )?; + let appended = BTreeMap::from([ + (StyleSection::NumFmts, Vec::new()), + (StyleSection::Fonts, appended_fonts), + (StyleSection::Fills, Vec::new()), + (StyleSection::Borders, Vec::new()), + (StyleSection::CellStyleXfs, Vec::new()), + (StyleSection::CellXfs, appended_xfs), + ]); + Ok((document.render(&appended)?, rewritten_cells)) +} + +pub(crate) fn rewrite_worksheet_font_colors( + xml: R, + output: W, + styles: &BTreeMap, +) -> Result<()> +where + R: BufRead, + W: Write, +{ + let mut reader = Reader::from_reader(xml); + reader.config_mut().trim_text(false); + let mut writer = Writer::new(output); + let mut buffer = Vec::new(); + loop { + let event = reader + .read_event_into(&mut buffer) + .map_err(|error| Error::insert_package(format!("invalid worksheet XML: {error}")))?; + match event { + Event::Start(cell) if local_name(cell.name().as_ref()) == b"c" => { + write_event(&mut writer, Event::Start(rewrite_selected_cell(&cell, styles)?))?; + } + Event::Empty(cell) if local_name(cell.name().as_ref()) == b"c" => { + write_event(&mut writer, Event::Empty(rewrite_selected_cell(&cell, styles)?))?; + } + Event::Eof => break, + event => write_event(&mut writer, event.into_owned())?, + } + buffer.clear(); + } + Ok(()) +} + fn rebase_style_xml( target_styles_xml: &[u8], donor_styles_xml: &[u8], @@ -205,6 +351,45 @@ impl XmlNode { }; Ok(Self { events }) } + + fn with_font_color(&self, color: RgbColor) -> Result { + let mut events = self.events.clone(); + let mut color_event = BytesStart::new("color"); + let rgb = format!("FF{:06X}", color.value()); + color_event.push_attribute(("rgb", rgb.as_str())); + let replacement = Event::Empty(color_event.into_owned()); + if events.len() == 1 && matches!(events.first(), Some(Event::Empty(_))) { + let root = self.root()?; + let qualified_name = root.name(); + let name = std::str::from_utf8(qualified_name.as_ref()) + .map_err(|_| Error::insert_package("font element name is not UTF-8"))?; + return Ok(Self { + events: vec![ + Event::Start(clone_start(root)?), + replacement, + Event::End(BytesEnd::new(name.to_owned())), + ], + }); + } + let mut index = 1; + while index + 1 < events.len() { + match &events[index] { + Event::Start(start) if local_name(start.name().as_ref()) == b"color" => { + let end = element_end(&events, index)?; + events.splice(index..=end, [replacement]); + return Ok(Self { events }); + } + Event::Empty(empty) if local_name(empty.name().as_ref()) == b"color" => { + events[index] = replacement; + return Ok(Self { events }); + } + _ => index += 1, + } + } + let insertion = events.len().saturating_sub(1); + events.insert(insertion, replacement); + Ok(Self { events }) + } } #[derive(Debug)] @@ -742,6 +927,20 @@ fn replace_attributes( Ok(output) } +fn rewrite_selected_cell( + cell: &BytesStart<'_>, + styles: &BTreeMap, +) -> Result> { + let Some(address) = attribute(cell, b"r")? else { + return clone_start(cell); + }; + let reference = address.parse::()?; + match styles.get(&reference) { + Some(style) => replace_attributes(cell, &[(b"s", style.to_string())]), + None => clone_start(cell), + } +} + fn clone_start(event: &BytesStart<'_>) -> Result> { replace_attributes(event, &[]) } diff --git a/miniexcel/src/lib.rs b/miniexcel/src/lib.rs index be05500..e6d69bc 100644 --- a/miniexcel/src/lib.rs +++ b/miniexcel/src/lib.rs @@ -21,6 +21,8 @@ pub mod serde_helpers; mod sheet; mod streaming; mod template; +#[cfg(not(target_arch = "wasm32"))] +mod workbook_edit; mod writer; pub use analytics::{ @@ -52,3 +54,5 @@ pub use rag::{ }; pub use sheet::{SheetInfo, SheetType, SheetVisibility}; pub use streaming::{ByteQuerySummary, QuerySummary}; +#[cfg(not(target_arch = "wasm32"))] +pub use workbook_edit::WorkbookEditor; diff --git a/miniexcel/src/workbook_edit.rs b/miniexcel/src/workbook_edit.rs new file mode 100644 index 0000000..559ffa9 --- /dev/null +++ b/miniexcel/src/workbook_edit.rs @@ -0,0 +1,38 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use crate::{CellReference, Result, RgbColor}; + +/// A deferred set of edits for one worksheet in an existing XLSX workbook. +#[derive(Debug)] +pub struct WorkbookEditor { + path: PathBuf, + sheet_name: String, + font_colors: Vec<(CellReference, RgbColor)>, +} + +impl WorkbookEditor { + pub(crate) fn new(path: PathBuf, sheet_name: String) -> Self { + Self { path, sheet_name, font_colors: Vec::new() } + } + + /// Sets a cell's font color while preserving its other style properties. + #[must_use] + pub fn set_font_color(mut self, cell: CellReference, color: RgbColor) -> Self { + self.font_colors.push((cell, color)); + self + } + + /// Orders, deduplicates, and atomically applies all deferred edits. + pub fn save(self) -> Result<()> { + let mut font_colors = BTreeMap::new(); + for (cell, color) in self.font_colors { + font_colors.insert(cell, color); + } + crate::insert::atomic::update_font_colors_to_path( + &self.path, + &self.sheet_name, + &font_colors, + ) + } +} diff --git a/miniexcel/tests/insert.rs b/miniexcel/tests/insert.rs index 30bc7ef..41ff149 100644 --- a/miniexcel/tests/insert.rs +++ b/miniexcel/tests/insert.rs @@ -7,9 +7,9 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime}; #[cfg(feature = "async")] use futures_util::StreamExt; use miniexcel::{ - CellValue, DynamicRow, ExistingSheetPolicy, HeaderMode, HeaderStyle, HorizontalAlignment, - InsertOptions, MiniExcel, ReadOptions, RgbColor, SheetVisibility, TableStyle, - TargetRelationshipPolicy, VerticalAlignment, WriteOptions, + CellReference, CellValue, DynamicRow, ExistingSheetPolicy, HeaderMode, HeaderStyle, + HorizontalAlignment, InsertOptions, MiniExcel, ReadOptions, RgbColor, SheetVisibility, + TableStyle, TargetRelationshipPolicy, VerticalAlignment, WriteOptions, }; use quick_xml::Reader; use quick_xml::events::{BytesStart, Event}; @@ -827,6 +827,122 @@ fn write_options_matrix_repeated_inserts_preserve_and_deduplicate_styles() { assert_eq!(final_inventory.sheets.len(), 11); } +#[test] +fn workbook_edit_applies_reverse_ordered_font_colors_when_saved() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("style-edit.xlsx"); + let schema = (1..=24).map(|column| format!("Column{column}")).collect::>(); + let rows = (1..=100).map(|row| { + let mut values = DynamicRow::new(); + for (index, name) in schema.iter().enumerate() { + values.insert(name.clone(), CellValue::Int((row * 100 + index) as i64)); + } + Ok(values) + }); + MiniExcel::insert_with_schema( + &path, + &schema, + rows, + &InsertOptions::new().with_sheet_name("Data"), + ) + .unwrap(); + + let initial_bytes = std::fs::read(&path).unwrap(); + let initial_inventory = package_inventory(&initial_bytes); + let initial_data = initial_inventory.sheets.iter().find(|sheet| sheet.name == "Data").unwrap(); + let initial_worksheet = entry_text(&initial_bytes, &format!("xl/{}", initial_data.target)); + let initial_styles = read_entry(&initial_bytes, "xl/styles.xml"); + let initial_a1 = cell_xf(&initial_styles, cell_style_index(&initial_worksheet, "A1")); + + MiniExcel::edit_sheet(&path, "Data") + .set_font_color("A1".parse::().unwrap(), RgbColor::new(0, 255, 0)) + .set_font_color("X100".parse::().unwrap(), RgbColor::new(0, 0, 255)) + .set_font_color("A1".parse::().unwrap(), RgbColor::new(255, 0, 0)) + .save() + .unwrap(); + + let bytes = std::fs::read(&path).unwrap(); + let inventory = package_inventory(&bytes); + let data = inventory.sheets.iter().find(|sheet| sheet.name == "Data").unwrap(); + let worksheet = entry_text(&bytes, &format!("xl/{}", data.target)); + let styles = read_entry(&bytes, "xl/styles.xml"); + let a1_font = cell_xf(&styles, cell_style_index(&worksheet, "A1")).font_id; + let x100_font = cell_xf(&styles, cell_style_index(&worksheet, "X100")).font_id; + assert_eq!(font_rgb(&styles, a1_font).as_deref(), Some("FFFF0000")); + assert_eq!(font_rgb(&styles, x100_font).as_deref(), Some("FF0000FF")); + let mut expected_a1 = initial_a1; + expected_a1.font_id = a1_font; + assert_eq!(cell_xf(&styles, cell_style_index(&worksheet, "A1")), expected_a1); + + let counts = parse_styles(&styles); + MiniExcel::edit_sheet(&path, "Data") + .set_font_color("X100".parse::().unwrap(), RgbColor::new(0, 0, 255)) + .set_font_color("A1".parse::().unwrap(), RgbColor::new(255, 0, 0)) + .save() + .unwrap(); + assert_eq!(parse_styles(&read_entry(&std::fs::read(&path).unwrap(), "xl/styles.xml")), counts); + + let before_failure = std::fs::read(&path).unwrap(); + assert!( + MiniExcel::edit_sheet(&path, "Data") + .set_font_color("XFD1048576".parse::().unwrap(), RgbColor::new(1, 2, 3),) + .save() + .is_err() + ); + assert_eq!(std::fs::read(&path).unwrap(), before_failure); +} + +#[test] +fn workbook_edit_without_operations_leaves_the_file_unchanged() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("style-edit-noop.xlsx"); + MiniExcel::insert( + &path, + &[dynamic_insert_row("No-op", 1)], + &InsertOptions::new().with_sheet_name("Data"), + ) + .unwrap(); + let before = std::fs::read(&path).unwrap(); + + MiniExcel::edit_sheet(&path, "Data").save().unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), before); +} + +#[test] +fn workbook_edit_reuses_font_and_xf_for_matching_source_styles() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("style-edit-dedup.xlsx"); + MiniExcel::insert( + &path, + &[dynamic_insert_row("First", 1), dynamic_insert_row("Second", 2)], + &InsertOptions::new().with_sheet_name("Data"), + ) + .unwrap(); + let before = std::fs::read(&path).unwrap(); + let before_counts = parse_styles(&read_entry(&before, "xl/styles.xml")); + + MiniExcel::edit_sheet(&path, "dAtA") + .set_font_color("A2".parse::().unwrap(), RgbColor::new(0x12, 0x34, 0x56)) + .set_font_color("A3".parse::().unwrap(), RgbColor::new(0x12, 0x34, 0x56)) + .save() + .unwrap(); + + let bytes = std::fs::read(&path).unwrap(); + let inventory = package_inventory(&bytes); + let data = inventory.sheets.iter().find(|sheet| sheet.name == "Data").unwrap(); + let worksheet = entry_text(&bytes, &format!("xl/{}", data.target)); + let styles = read_entry(&bytes, "xl/styles.xml"); + let after_counts = parse_styles(&styles); + let a2_style = cell_style_index(&worksheet, "A2"); + let a3_style = cell_style_index(&worksheet, "A3"); + + assert_eq!(a2_style, a3_style); + assert_eq!(after_counts.fonts, before_counts.fonts + 1); + assert_eq!(after_counts.cell_xfs, before_counts.cell_xfs + 1); + assert_eq!(font_rgb(&styles, cell_xf(&styles, a2_style).font_id).as_deref(), Some("FF123456")); +} + #[test] fn write_options_matrix_hundred_insert_stress_has_unique_ids_and_bounded_growth() { let directory = tempfile::tempdir().unwrap(); @@ -2500,6 +2616,38 @@ fn cell_xf_signatures(styles_xml: &[u8]) -> Vec { (0..count).map(|index| cell_xf(styles_xml, index)).collect() } +fn font_rgb(styles_xml: &[u8], font_index: usize) -> Option { + let mut reader = Reader::from_reader(styles_xml); + let mut in_fonts = false; + let mut current_font = None; + let mut next_font = 0; + loop { + match reader.read_event().expect("parse styles XML") { + Event::Start(event) if local_name(event.name().as_ref()) == b"fonts" => { + in_fonts = true; + } + Event::End(event) if local_name(event.name().as_ref()) == b"fonts" => { + return None; + } + Event::Start(event) if in_fonts && local_name(event.name().as_ref()) == b"font" => { + current_font = Some(next_font); + next_font += 1; + } + Event::End(event) if in_fonts && local_name(event.name().as_ref()) == b"font" => { + current_font = None; + } + Event::Start(event) | Event::Empty(event) + if current_font == Some(font_index) + && local_name(event.name().as_ref()) == b"color" => + { + return attribute(&event, b"rgb"); + } + Event::Eof => return None, + _ => {} + } + } +} + fn attribute(event: &BytesStart<'_>, key: &[u8]) -> Option { event .attributes() diff --git a/scripts/compare-nuget-v1-rust.ps1 b/scripts/compare-nuget-v1-rust.ps1 index b73d4eb..d7aa672 100644 --- a/scripts/compare-nuget-v1-rust.ps1 +++ b/scripts/compare-nuget-v1-rust.ps1 @@ -12,6 +12,8 @@ param( [ValidateRange(1, 26)] [int] $Columns = 10, + [switch] $OmitDimension, + [ValidateRange(1, 100)] [int] $Passes = 3, @@ -47,7 +49,12 @@ if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { $OutputDirectory = Join-Path $repositoryRoot 'target/benchmarks/nuget-v1' } $OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) -$workbook = Join-Path $OutputDirectory "benchmark-$Rows`x$Columns.xlsx" +$workbookName = if ($OmitDimension) { + "benchmark-no-dimension-$Rows`x$Columns.xlsx" +} else { + "benchmark-$Rows`x$Columns.xlsx" +} +$workbook = Join-Path $OutputDirectory $workbookName $template = Join-Path $OutputDirectory 'template.xlsx' if ([string]::IsNullOrWhiteSpace($MiniExcelVersion)) { @@ -102,7 +109,8 @@ if ($LASTEXITCODE -ne 0) { throw 'Benchmark build failed.' } & cargo +1.85.0 build --release -p miniexcel --example nuget_method_benchmark --locked if ($LASTEXITCODE -ne 0) { throw 'Native Rust benchmark build failed.' } -& dotnet $runner generate $workbook $Rows $Columns +$generateMode = if ($OmitDimension) { 'generate-no-dimension' } else { 'generate' } +& dotnet $runner $generateMode $workbook $Rows $Columns if ($LASTEXITCODE -ne 0) { throw 'Benchmark workbook generation failed.' } & dotnet $runner verify $workbook if ($LASTEXITCODE -ne 0) { throw 'MiniExcel and MiniExcel.Rust returned different data.' } @@ -331,6 +339,7 @@ $report = [ordered]@{ RustRevision = (& git -C $repositoryRoot rev-parse HEAD).Trim() Rows = $Rows Columns = $Columns + HasWorksheetDimension = -not $OmitDimension.IsPresent WorkbookSha256 = (Get-FileHash $workbook -Algorithm SHA256).Hash.ToLowerInvariant() PackageSha256 = (Get-FileHash $package -Algorithm SHA256).Hash.ToLowerInvariant() Iterations = $Iterations @@ -353,6 +362,7 @@ $markdown.Add("- MiniExcel: $MiniExcelVersion") $markdown.Add("- MiniExcel.Rust: $MiniExcelRustVersion") $markdown.Add("- MiniExcel.Rust native: 0.4.0 ($($report.RustRevision))") $markdown.Add("- Workbook: $Rows rows x $Columns columns") +$markdown.Add("- Worksheet dimension: $(if ($OmitDimension) { 'omitted' } else { 'declared' })") $markdown.Add("- Iterations: $Iterations fresh processes per runtime and scenario") $markdown.Add('') $markdown.Add('| Method | Scenario | Runtime | Median elapsed (ms) | Rows/s | First row (ms) | Allocated (MB) | Peak working set (MB) |') diff --git a/web-demo/playwright.config.mjs b/web-demo/playwright.config.mjs index eb20e77..e7b3da3 100644 --- a/web-demo/playwright.config.mjs +++ b/web-demo/playwright.config.mjs @@ -19,5 +19,9 @@ export default defineConfig({ projects: [ { name: "desktop", use: { ...devices["Desktop Chrome"] } }, { name: "mobile", use: { ...devices["Pixel 7"] } }, + { + name: "mobile-narrow", + use: { ...devices["Pixel 7"], viewport: { width: 320, height: 800 } }, + }, ], }); diff --git a/web-demo/public/app.js b/web-demo/public/app.js index 93210b9..609b397 100644 --- a/web-demo/public/app.js +++ b/web-demo/public/app.js @@ -1,4 +1,10 @@ const MAX_FILE_SIZE = 64 * 1024 * 1024; +const LAYOUT_STORAGE_KEY = "miniexcel.browser-lab.layout/v1"; +const RAIL_WIDTH_DEFAULT = 380; +const RAIL_WIDTH_MIN = 260; +const RAIL_WIDTH_MAX = 720; +const RAIL_WIDTH_RATIO = 0.6; +const RAIL_KEYBOARD_STEP = 16; const worker = new Worker(new URL("./analysis-worker.js", import.meta.url), { type: "module" }); let nextRequestId = 1; const pendingRequests = new Map(); @@ -17,11 +23,14 @@ const state = { columnTypes: new Map(), builderInitialized: false, toastTimer: null, + railWidth: RAIL_WIDTH_DEFAULT, + railCollapsed: false, }; const elements = Object.fromEntries( [ - "runtimeStatus", "fileInput", "openFileButton", "loadDemoButton", "downloadDemoButton", + "runtimeStatus", "workspace", "controlRail", "railSplitter", "railToggleButton", "fileInput", + "openFileButton", "loadDemoButton", "downloadDemoButton", "downloadJsonButton", "downloadChunksButton", "downloadMarkdownChunksButton", "downloadManifestButton", "downloadMarkdownButton", "dropZone", "fileName", "fileSize", "sheetCount", "sheetSelect", "startCellInput", "endCellInput", @@ -51,6 +60,7 @@ worker.addEventListener("error", (event) => { pendingRequests.clear(); }); +initLayout(); bindEvents(); boot(); @@ -130,6 +140,120 @@ function bindEvents() { elements.previewTab.addEventListener("click", () => setTab("grid")); elements.jsonTab.addEventListener("click", () => setTab("json")); elements.markdownTab.addEventListener("click", () => setTab("markdown")); + elements.railToggleButton.addEventListener("click", () => setRailCollapsed(!state.railCollapsed)); + bindRailSplitter(); + window.addEventListener("resize", applyRailWidth); +} + +function initLayout() { + const stored = readStoredLayout(); + state.railWidth = stored.width; + setRailCollapsed(stored.collapsed, { persist: false }); +} + +function readStoredLayout() { + const fallback = { width: RAIL_WIDTH_DEFAULT, collapsed: false }; + try { + const raw = window.localStorage.getItem(LAYOUT_STORAGE_KEY); + if (!raw) return fallback; + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return fallback; + const { width, collapsed } = parsed; + if (!Number.isFinite(width) || width < RAIL_WIDTH_MIN || width > RAIL_WIDTH_MAX) return fallback; + if (typeof collapsed !== "boolean") return fallback; + return { width, collapsed }; + } catch { + return fallback; + } +} + +function persistLayout() { + try { + window.localStorage.setItem( + LAYOUT_STORAGE_KEY, + JSON.stringify({ width: Math.round(state.railWidth), collapsed: state.railCollapsed }), + ); + } catch { + // Storage can be unavailable (private browsing); the layout then only lasts for this session. + } +} + +function railWidthBounds() { + const available = elements.workspace.clientWidth || window.innerWidth; + const scaled = Math.round(available * RAIL_WIDTH_RATIO); + return { min: RAIL_WIDTH_MIN, max: Math.max(RAIL_WIDTH_MIN, Math.min(RAIL_WIDTH_MAX, scaled)) }; +} + +function applyRailWidth() { + const { min, max } = railWidthBounds(); + const width = Math.min(Math.max(Math.round(state.railWidth), min), max); + elements.workspace.style.setProperty("--rail-width", `${width}px`); + elements.railSplitter.setAttribute("aria-valuemin", String(min)); + elements.railSplitter.setAttribute("aria-valuemax", String(max)); + elements.railSplitter.setAttribute("aria-valuenow", String(width)); +} + +function setRailWidth(width, { persist = true } = {}) { + const { min, max } = railWidthBounds(); + state.railWidth = Math.min(Math.max(Math.round(width), min), max); + applyRailWidth(); + if (persist) persistLayout(); +} + +function setRailCollapsed(collapsed, { persist = true } = {}) { + state.railCollapsed = collapsed; + elements.controlRail.hidden = collapsed; + elements.railSplitter.hidden = collapsed; + elements.workspace.classList.toggle("is-collapsed", collapsed); + elements.railToggleButton.setAttribute("aria-expanded", String(!collapsed)); + elements.railToggleButton.title = collapsed ? "Show workbook controls" : "Hide workbook controls"; + if (!collapsed) applyRailWidth(); + if (persist) persistLayout(); +} + +function bindRailSplitter() { + let activePointer = null; + + elements.railSplitter.addEventListener("pointerdown", (event) => { + if (state.railCollapsed || event.button !== 0) return; + activePointer = event.pointerId; + elements.railSplitter.setPointerCapture(event.pointerId); + document.body.classList.add("is-resizing"); + }); + + elements.railSplitter.addEventListener("pointermove", (event) => { + if (activePointer === null || event.pointerId !== activePointer) return; + const left = elements.controlRail.getBoundingClientRect().left; + setRailWidth(event.clientX - left, { persist: false }); + }); + + const endResize = (event) => { + if (activePointer === null || (event && event.pointerId !== activePointer)) return; + if (elements.railSplitter.hasPointerCapture(activePointer)) { + elements.railSplitter.releasePointerCapture(activePointer); + } + activePointer = null; + document.body.classList.remove("is-resizing"); + persistLayout(); + }; + elements.railSplitter.addEventListener("pointerup", endResize); + elements.railSplitter.addEventListener("pointercancel", endResize); + + elements.railSplitter.addEventListener("dblclick", () => setRailWidth(RAIL_WIDTH_DEFAULT)); + + elements.railSplitter.addEventListener("keydown", (event) => { + const { min, max } = railWidthBounds(); + const current = Math.round(elements.controlRail.getBoundingClientRect().width) || state.railWidth; + const next = { + ArrowLeft: current - RAIL_KEYBOARD_STEP, + ArrowRight: current + RAIL_KEYBOARD_STEP, + Home: min, + End: max, + }[event.key]; + if (next === undefined) return; + event.preventDefault(); + setRailWidth(next); + }); } async function loadFile(file) { diff --git a/web-demo/public/index.html b/web-demo/public/index.html index fe9c513..8ec74ba 100644 --- a/web-demo/public/index.html +++ b/web-demo/public/index.html @@ -3,8 +3,33 @@ - MiniExcel Browser Lab + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -25,11 +50,23 @@ .NET Rust + + + -
-