Skip to content

Commit 2436ba0

Browse files
committed
Add strict in-place XLSX worksheet replacement
1 parent 8c5a203 commit 2436ba0

12 files changed

Lines changed: 1080 additions & 89 deletions

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ let counts = MiniExcel::save_as_sheets(
271271

272272
Configure final sheet names as visible, hidden, or very hidden with `with_sheet_visibility(name, SheetVisibility::...)`. Matching is case-insensitive, the first visible sheet becomes active, and unknown names or an all-hidden workbook are rejected before output is created. Hidden states are UI organization, not data protection; hidden worksheets remain queryable.
273273

274-
## Append To An Existing Workbook
274+
## Append Or Replace A Worksheet
275275

276276
Use `MiniExcel::insert()` to atomically append a visible worksheet. A missing path creates a new workbook with the same row-count semantics:
277277

@@ -291,7 +291,7 @@ assert_eq!(count, 1);
291291

292292
`insert_with_schema()` accepts a fallible, one-pass dynamic iterator. Source rows are disk-spooled and the constant-memory backend retains only the current row while generating the donor workbook; style rebasing currently materializes the generated worksheet XML. `insert_serialized()` accepts Serde structs. Existing unrelated ZIP entries, worksheet identities, formulas, and cached values are preserved, and an existing workbook is replaced only after the rewritten package validates and syncs.
293293

294-
The default `ExistingSheetPolicy::Reject` rejects duplicate worksheet names case-insensitively. `ExistingSheetPolicy::Replace` and `TargetRelationshipPolicy::RemoveSupported` are reserved for the replacement milestone and currently return an error before output is created. Insert writes XLSX packages, rejects macro-enabled `.xlsm` paths, creates visible worksheets, and rejects `WriteOptions::with_overwrite_file(true)` because workbook replacement is controlled by the insert policy.
294+
The default `ExistingSheetPolicy::Reject` rejects duplicate worksheet names case-insensitively. Use `ExistingSheetPolicy::Replace` to replace a worksheet in place while preserving its workbook order, ID, relationship/path, visibility, and active state. The default `TargetRelationshipPolicy::Reject` accepts only a plain target with no worksheet relationships. `RemoveSupported` can remove target-owned tables, drawings with exclusively owned images, comments, VML drawings, and external hyperlinks; pivots, external links, unknown relationships, and shared/global parts are rejected or preserved conservatively. Insert writes XLSX packages, rejects macro-enabled `.xlsm` paths, and rejects `WriteOptions::with_overwrite_file(true)` because workbook replacement is controlled by the insert policy.
295295

296296
## Typed Writing
297297

‎README.zh-CN.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ let counts = MiniExcel::save_as_sheets(
271271

272272
使用 `with_sheet_visibility(name, SheetVisibility::...)` 可按最终 sheet name 配置 visible、hidden 或 very hidden,名称匹配不区分大小写。第一个 visible sheet 自动成为 active;未知名称或全部隐藏的 workbook 会在创建输出前报错。隐藏状态只用于 UI 组织,不是数据保护,隐藏 worksheet 仍可查询。
273273

274-
## 向现有工作簿追加 Worksheet
274+
## 追加或替换 Worksheet
275275

276276
使用 `MiniExcel::insert()` 原子追加一张 visible worksheet。路径不存在时会新建 workbook,并保持相同的数据 row count 语义:
277277

@@ -291,7 +291,7 @@ assert_eq!(count, 1);
291291

292292
`insert_with_schema()` 接受可返回错误、只消费一次的动态 iterator。源 row 会先落盘 spool,constant-memory backend 在生成 donor workbook 时只保留当前 row;style rebase 当前仍会物化生成的 worksheet XML。`insert_serialized()` 接受 Serde struct。现有无关 ZIP entry、worksheet identity、formula 和 cached value 均会保留;只有重写 package 完成验证并同步后,才原子替换现有 workbook。
293293

294-
默认的 `ExistingSheetPolicy::Reject` 会不区分大小写地拒绝重复 worksheet name。`ExistingSheetPolicy::Replace` 与 `TargetRelationshipPolicy::RemoveSupported` 预留给后续 replacement 里程碑,当前会在创建输出前返回错误。Insert 写入 XLSX package,拒绝 macro-enabled `.xlsm` path,只创建 visible worksheet,并拒绝 `WriteOptions::with_overwrite_file(true)`,因为 workbook replacement 由 Insert policy 控制。
294+
默认的 `ExistingSheetPolicy::Reject` 会不区分大小写地拒绝重复 worksheet name。使用 `ExistingSheetPolicy::Replace` 可原位替换 worksheet,并保留其 workbook 顺序、ID、relationship/path、visibility 与 active state。默认 `TargetRelationshipPolicy::Reject` 只接受没有 worksheet relationship 的 plain target。`RemoveSupported` 可删除 target-owned table、drawing 及其独占 image、comment、VML drawing 和 external hyperlink;pivot、external link、未知 relationship 与 shared/global part 会被拒绝或保守保留。Insert 写入 XLSX package,拒绝 macro-enabled `.xlsm` path,并拒绝 `WriteOptions::with_overwrite_file(true)`,因为 workbook replacement 由 Insert policy 控制。
295295

296296
## 类型化写入
297297

‎docs/compatibility.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ The latest `calamine 0.36` and `rust_xlsxwriter 0.97` require Rust 1.88. The MVP
4747
| Dynamic export | `save_as()` / `save_as_with_schema()` | Map serialization is implemented internally |
4848
| Typed export | `save_as_serialized<T>()` | Uses Serde mapping internally |
4949
| Multi-sheet export | `save_as_sheets()` / `save_as_serialized_sheets()` | Preserves input sheet order and returns data-row counts |
50-
| `InsertSheet` append | `insert()` / `insert_with_schema()` / `insert_serialized()` | Atomically appends a visible worksheet; missing paths create a workbook; replacement is deferred |
50+
| `InsertSheet` append/replace | `insert()` / `insert_with_schema()` / `insert_serialized()` | Atomic append; strict in-place replacement preserves sheet identity and has an explicit relationship policy |
5151
| Per-sheet visibility | `WriteOptions::with_sheet_visibility()` | Visible, hidden, and very hidden; first visible sheet is active |
5252
| `overwriteFile` | `WriteOptions::with_overwrite_file()` | Defaults to `false`; existing paths require explicit opt-in |
5353
| `FreezeRowCount` / `FreezeColumnCount` | `WriteOptions::with_freeze_row_count()` / `with_freeze_column_count()` | Defaults to one frozen row and zero frozen columns |
@@ -174,12 +174,12 @@ The contract covers only the current common surface: dynamic/typed path queries,
174174
| Addressed JSONL/Markdown/manifest RAG export | Rust research extension | No |
175175
| Async APIs, DataReader, stream ownership | Deferred | No |
176176
| Append worksheet to existing `.xlsx` workbook | Implemented and atomically committed | Rust tests; shared parity contract not yet extended |
177-
| Replace/edit existing worksheets | Deferred | No |
177+
| Strict worksheet replacement | Implemented for plain targets and supported target-owned closures | Rust tests; calculation policy remains deferred |
178178
| CSV and legacy formats | Deferred | No |
179179
| Advanced templates, pictures, merges, comments | Deferred | No |
180180

181181
This matrix is the coverage claim: Rust does not yet provide complete API parity with the current .NET packages.
182182

183183
## Deferred Work
184184

185-
SQL text parsing, `HAVING`, `ORDER BY`, joins, windows, pivots, disk-spill aggregation, vector indexing, model calls, CSV providers, old Excel formats, advanced template directives and sheet cloning, images, merged-cell APIs, formula calculation/dependency expansion, formula authoring, general styling, replacing/editing existing worksheets, async I/O, and streaming from caller-owned readers require separate design and acceptance milestones.
185+
SQL text parsing, `HAVING`, `ORDER BY`, joins, windows, pivots, disk-spill aggregation, vector indexing, model calls, CSV providers, old Excel formats, advanced template directives and sheet cloning, image authoring, merged-cell APIs, formula calculation/dependency expansion, formula authoring, general styling, replacement calculation-chain policy, async I/O, and streaming from caller-owned readers require separate design and acceptance milestones.

‎docs/compatibility.zh-CN.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Rust MVP 在统一的 `MiniExcel` facade 后实现最小但实用的 MiniExcel
4747
| 动态导出 | `save_as()` / `save_as_with_schema()` | map 序列化在内部实现 |
4848
| 类型化导出 | `save_as_serialized<T>()` | 内部使用 Serde 映射 |
4949
| 多工作表导出 | `save_as_sheets()` / `save_as_serialized_sheets()` | 保留输入工作表顺序并返回数据行数 |
50-
| `InsertSheet` append | `insert()` / `insert_with_schema()` / `insert_serialized()` | 原子追加 visible worksheet;路径不存在时创建 workbook;replacement 延后 |
50+
| `InsertSheet` append/replace | `insert()` / `insert_with_schema()` / `insert_serialized()` | 原子 append;严格原位 replacement 保留 sheet identity,并使用显式 relationship policy |
5151
| 每表 visibility | `WriteOptions::with_sheet_visibility()` | visible、hidden、very hidden;第一个 visible sheet 为 active |
5252
| `overwriteFile` | `WriteOptions::with_overwrite_file()` | 默认 `false`;已有路径需要显式允许覆盖 |
5353
| `FreezeRowCount` / `FreezeColumnCount` | `WriteOptions::with_freeze_row_count()` / `with_freeze_column_count()` | 默认冻结一行、零列 |
@@ -174,12 +174,12 @@ Rust workflow 会在 Linux 和 Windows 上运行 Rust 契约。其 .NET parity j
174174
| 带地址 JSONL/Markdown/manifest RAG 导出 | Rust 研究扩展 | 否 |
175175
| Async API、DataReader、stream ownership | 延后 | 否 |
176176
| 向现有 `.xlsx` workbook 追加 worksheet | 已实现并原子提交 | Rust 测试;共享 parity contract 尚未扩展 |
177-
| 替换/编辑现有 worksheet | 延后 | 否 |
177+
| 严格 worksheet replacement | 已支持 plain target 与受支持的 target-owned closure | Rust 测试;calculation policy 仍延后 |
178178
| CSV 和旧格式 | 延后 | 否 |
179179
| 高级 template、picture、merge、comment | 延后 | 否 |
180180

181181
此矩阵就是覆盖声明:Rust 目前尚未提供与当前 .NET package 完整的 API 等价性。
182182

183183
## 延后工作
184184

185-
SQL 文本解析、`HAVING`、`ORDER BY`、join、window、pivot、磁盘 spill 聚合、向量索引、模型调用、CSV provider、旧 Excel 格式、高级 template 指令与 sheet 克隆、image、merged-cell API、公式计算/依赖展开、公式编写、通用 style、替换/编辑现有 worksheet、async I/O,以及从调用方拥有的 reader 流式读取,都需要独立的设计与验收里程碑。
185+
SQL 文本解析、`HAVING`、`ORDER BY`、join、window、pivot、磁盘 spill 聚合、向量索引、模型调用、CSV provider、旧 Excel 格式、高级 template 指令与 sheet 克隆、image authoring、merged-cell API、公式计算/依赖展开、公式编写、通用 style、replacement calculation-chain policy、async I/O,以及从调用方拥有的 reader 流式读取,都需要独立的设计与验收里程碑。

‎docs/insert-existing-workbook-plan.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -278,12 +278,14 @@ Acceptance:
278278

279279
Depends on Task 6.
280280

281-
- [ ] Preserve the target sheet element's order, `sheetId`, relationship ID, target path, visibility, and active state.
282-
- [ ] Inspect the target worksheet relationship closure before replacement.
283-
- [ ] Under default `TargetRelationshipPolicy::Reject`, reject tables, drawings, comments, hyperlinks, pivots, external links, and unknown relationship types.
284-
- [ ] Under `RemoveSupported`, remove only explicitly supported target-owned parts and their content-type entries; never delete shared/global parts.
285-
- [ ] Replace only the target worksheet XML and its local AutoFilter defined name.
286-
- [ ] Reject case-insensitive duplicate ambiguity.
281+
- [x] Preserve the target sheet element's order, `sheetId`, relationship ID, target path, visibility, and active state.
282+
- [x] Inspect the target worksheet relationship closure before replacement.
283+
- [x] Under default `TargetRelationshipPolicy::Reject`, reject tables, drawings, comments, hyperlinks, pivots, external links, and unknown relationship types.
284+
- [x] Under `RemoveSupported`, remove only explicitly supported target-owned parts and their content-type entries; never delete shared/global parts.
285+
- [x] Replace only the target worksheet XML and its local AutoFilter defined name.
286+
- [x] Reject case-insensitive duplicate ambiguity.
287+
288+
Completed on 2026-08-24. Focused test: `cargo +1.85.0 test -p miniexcel --test insert replace_sheet --locked`.
287289

288290
Acceptance:
289291

‎docs/insert-existing-workbook-plan.zh-CN.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -278,12 +278,14 @@ Donor-workbook 方案复用经过测试的 `XlsxWriter`,避免重复实现 ser
278278

279279
依赖 Task 6。
280280

281-
- [ ] 保留目标 sheet element 的顺序、`sheetId`、relationship ID、target path、visibility 与 active 状态。
282-
- [ ] replacement 前检查目标 worksheet relationship closure。
283-
- [ ] 默认 `TargetRelationshipPolicy::Reject` 拒绝 table、drawing、comment、hyperlink、pivot、external link 和未知 relationship type。
284-
- [ ] `RemoveSupported` 只删除明确支持的 target-owned part 与 content-type entry,绝不删除 shared/global part。
285-
- [ ] 只替换目标 worksheet XML 与其 local AutoFilter defined name。
286-
- [ ] 拒绝大小写不敏感时产生的 duplicate ambiguity。
281+
- [x] 保留目标 sheet element 的顺序、`sheetId`、relationship ID、target path、visibility 与 active 状态。
282+
- [x] replacement 前检查目标 worksheet relationship closure。
283+
- [x] 默认 `TargetRelationshipPolicy::Reject` 拒绝 table、drawing、comment、hyperlink、pivot、external link 和未知 relationship type。
284+
- [x] `RemoveSupported` 只删除明确支持的 target-owned part 与 content-type entry,绝不删除 shared/global part。
285+
- [x] 只替换目标 worksheet XML 与其 local AutoFilter defined name。
286+
- [x] 拒绝大小写不敏感时产生的 duplicate ambiguity。
287+
288+
已于 2026-08-24 完成。聚焦测试:`cargo +1.85.0 test -p miniexcel --test insert replace_sheet --locked`。
287289

288290
验收:
289291

‎miniexcel/src/facade.rs‎

Lines changed: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -474,9 +474,13 @@ impl MiniExcel {
474474
writer.save(path, false)?;
475475
return Ok(rows.len());
476476
}
477-
crate::insert::atomic::append_to_path(path, options.write_options().sheet_name(), || {
478-
crate::insert::donor::DonorBuilder::from_dynamic(rows, options.write_options())
479-
})
477+
crate::insert::atomic::insert_to_path(
478+
path,
479+
options.write_options().sheet_name(),
480+
options.existing_sheet_policy(),
481+
options.target_relationship_policy(),
482+
|| crate::insert::donor::DonorBuilder::from_dynamic(rows, options.write_options()),
483+
)
480484
}
481485

482486
/// Inserts a one-pass dynamic row iterator using an explicit schema.
@@ -506,13 +510,19 @@ impl MiniExcel {
506510
options.write_options(),
507511
);
508512
}
509-
crate::insert::atomic::append_to_path(path, options.write_options().sheet_name(), || {
510-
crate::insert::donor::DonorBuilder::from_dynamic_iter(
511-
schema,
512-
rows,
513-
options.write_options(),
514-
)
515-
})
513+
crate::insert::atomic::insert_to_path(
514+
path,
515+
options.write_options().sheet_name(),
516+
options.existing_sheet_policy(),
517+
options.target_relationship_policy(),
518+
|| {
519+
crate::insert::donor::DonorBuilder::from_dynamic_iter(
520+
schema,
521+
rows,
522+
options.write_options(),
523+
)
524+
},
525+
)
516526
}
517527

518528
/// Inserts Serde-serializable rows as a new worksheet.
@@ -536,9 +546,13 @@ impl MiniExcel {
536546
writer.save(path, false)?;
537547
return Ok(rows.len());
538548
}
539-
crate::insert::atomic::append_to_path(path, options.write_options().sheet_name(), || {
540-
crate::insert::donor::DonorBuilder::from_serialized(rows, options.write_options())
541-
})
549+
crate::insert::atomic::insert_to_path(
550+
path,
551+
options.write_options().sheet_name(),
552+
options.existing_sheet_policy(),
553+
options.target_relationship_policy(),
554+
|| crate::insert::donor::DonorBuilder::from_serialized(rows, options.write_options()),
555+
)
542556
}
543557

544558
/// Fills an existing XLSX template and writes a new workbook.
@@ -586,21 +600,12 @@ fn validate_insert_options(path: &Path, options: &InsertOptions) -> Result<()> {
586600
"overwrite_file does not apply to Insert; use ExistingSheetPolicy",
587601
));
588602
}
589-
match options.existing_sheet_policy() {
590-
ExistingSheetPolicy::Reject => {}
591-
ExistingSheetPolicy::Replace => {
592-
return Err(crate::Error::unsupported_package_feature(
593-
"worksheet replacement is not supported yet",
594-
));
595-
}
596-
}
597-
match options.target_relationship_policy() {
598-
TargetRelationshipPolicy::Reject => {}
599-
TargetRelationshipPolicy::RemoveSupported => {
600-
return Err(crate::Error::unsupported_package_feature(
601-
"removing target worksheet relationships is not supported yet",
602-
));
603-
}
603+
if options.existing_sheet_policy() == ExistingSheetPolicy::Reject
604+
&& options.target_relationship_policy() != TargetRelationshipPolicy::Reject
605+
{
606+
return Err(crate::Error::invalid_write_options(
607+
"target relationship removal requires ExistingSheetPolicy::Replace",
608+
));
604609
}
605610
validate_single_sheet_options(options.write_options())
606611
}

0 commit comments

Comments
 (0)