Skip to content

Commit 1da11a1

Browse files
committed
Complete XLSX insert WriteOptions support
1 parent c6168bf commit 1da11a1

8 files changed

Lines changed: 609 additions & 36 deletions

File tree

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,17 +317,23 @@ Acceptance:
317317

318318
Depends on Tasks 6-8.
319319

320-
- [ ] Run inserted sheets through all current write options: headers, AutoFilter, panes, RTL, AutoWidth, fixed/hidden columns, body wrap/alignment, header style, `TableStyle`, number formats, sheet visibility, and case-insensitive names.
321-
- [ ] Add dynamic, explicit-schema, Serde, empty, header-only, and multi-insert sequences.
322-
- [ ] Verify that donor style rebasing does not change existing style IDs after repeated inserts.
323-
- [ ] Add a 100-insert stress test for ID/path collisions and package growth.
320+
- [x] Run inserted sheets through all current write options: headers, AutoFilter, panes, RTL, AutoWidth, fixed/hidden columns, body wrap/alignment, header style, `TableStyle`, number formats, sheet visibility, and case-insensitive names.
321+
- [x] Add dynamic, explicit-schema, Serde, empty, header-only, and multi-insert sequences.
322+
- [x] Verify that donor style rebasing does not change existing style IDs after repeated inserts.
323+
- [x] Add a 100-insert stress test for ID/path collisions and package growth.
324324

325325
Acceptance:
326326

327327
- Every supported new-workbook option has an Insert test.
328328
- Repeated inserts remain readable by Rust, LibreOffice, and the .NET Open XML SDK.
329329
- Focused command: `cargo +1.85.0 test -p miniexcel --test insert write_options_matrix --locked`.
330330

331+
Completed on 2026-08-25. The focused matrix covers dynamic rows, explicit-schema
332+
iterators, Serde rows, empty/header-only sheets, append visibility, style stability, and a
333+
100-insert collision/growth run. The generated 101-sheet stress workbook was read by the Rust
334+
CLI, round-tripped by LibreOffice 26.2.1.2, and validated with zero Office 2019 schema errors by
335+
the .NET Open XML SDK 3.5.1 before and after the roundtrip.
336+
331337
### Task 10: Separate Reader/Writer Insert API
332338

333339
Depends on Task 9.

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,17 +317,23 @@ Donor-workbook 方案复用经过测试的 `XlsxWriter`,避免重复实现 ser
317317

318318
依赖 Task 6-8。
319319

320-
- [ ] 插入 sheet 覆盖全部当前 write option:header、AutoFilter、pane、RTL、AutoWidth、固定/隐藏列、body wrap/alignment、header style、`TableStyle`、number format、sheet visibility 和大小写不敏感 name。
321-
- [ ] 增加 dynamic、显式 schema、Serde、empty、header-only 和连续多次 insert。
322-
- [ ] 验证 donor style rebase 在重复 insert 后不改变现有 style ID。
323-
- [ ] 增加 100 次 insert 的 ID/path collision 与 package growth stress test。
320+
- [x] 插入 sheet 覆盖全部当前 write option:header、AutoFilter、pane、RTL、AutoWidth、固定/隐藏列、body wrap/alignment、header style、`TableStyle`、number format、sheet visibility 和大小写不敏感 name。
321+
- [x] 增加 dynamic、显式 schema、Serde、empty、header-only 和连续多次 insert。
322+
- [x] 验证 donor style rebase 在重复 insert 后不改变现有 style ID。
323+
- [x] 增加 100 次 insert 的 ID/path collision 与 package growth stress test。
324324

325325
验收:
326326

327327
- 每个已支持 new-workbook option 都有 Insert test。
328328
- 重复 insert 后仍可被 Rust、LibreOffice 和 .NET Open XML SDK 读取。
329329
- 聚焦命令:`cargo +1.85.0 test -p miniexcel --test insert write_options_matrix --locked`。
330330

331+
已于 2026-08-25 完成。聚焦矩阵覆盖 dynamic row、显式 schema iterator、Serde row、
332+
empty/header-only sheet、append visibility、style 稳定性,以及 100 次 insert 的
333+
collision/growth 压力测试。生成的 101-sheet 压力工作簿已由 Rust CLI 读取,经
334+
LibreOffice 26.2.1.2 roundtrip,并在 roundtrip 前后均通过 .NET Open XML SDK 3.5.1
335+
的 Office 2019 schema 验证,错误数为零。
336+
331337
### Task 10:分离 Reader/Writer Insert API
332338

333339
依赖 Task 9。

‎miniexcel/src/facade.rs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ use serde::de::DeserializeOwned;
77
use crate::streaming::{StreamingRows, StreamingStructuredRows, StreamingTypedRows};
88
use crate::writer::XlsxWriter;
99
#[cfg(not(target_arch = "wasm32"))]
10-
use crate::writer::{validate_dimensions, validate_schema, validate_single_sheet_options};
10+
use crate::writer::{
11+
validate_dimensions, validate_insert_sheet_options, validate_schema,
12+
validate_single_sheet_options,
13+
};
1114
use crate::{
1215
AnalysisResult, ByteQuerySummary, DynamicRow, ExcelRange, QueryPlan, QuerySummary, RagChunk,
1316
RagExport, RagExportOptions, RagManifest, ReadOptions, Result, SheetInfo, StructuredRow,
@@ -607,5 +610,9 @@ fn validate_insert_options(path: &Path, options: &InsertOptions) -> Result<()> {
607610
"target relationship removal requires ExistingSheetPolicy::Replace",
608611
));
609612
}
610-
validate_single_sheet_options(options.write_options())
613+
if path.exists() {
614+
validate_insert_sheet_options(options.write_options())
615+
} else {
616+
validate_single_sheet_options(options.write_options())
617+
}
611618
}

‎miniexcel/src/insert/donor.rs‎

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use super::package::{DefinedName, PackageInventory};
1111
use crate::writer::XlsxWriter;
1212
#[cfg(not(target_arch = "wasm32"))]
1313
use crate::writer::validate_dimensions;
14-
use crate::{DynamicRow, Error, Result, WriteOptions};
14+
use crate::{DynamicRow, Error, Result, SheetVisibility, WriteOptions};
1515

1616
const STYLES_PATH: &str = "xl/styles.xml";
1717
const SHARED_STRINGS_PATH: &str = "xl/sharedStrings.xml";
@@ -32,6 +32,7 @@ pub(crate) struct DonorStyleModel {
3232
#[derive(Clone, Debug, Eq, PartialEq)]
3333
pub(crate) struct DonorWorksheet {
3434
pub(crate) sheet_name: String,
35+
pub(crate) visibility: SheetVisibility,
3536
pub(crate) worksheet_xml: Vec<u8>,
3637
pub(crate) data_row_count: usize,
3738
pub(crate) styles: DonorStyleModel,
@@ -47,7 +48,11 @@ impl DonorBuilder {
4748
) -> Result<DonorWorksheet> {
4849
let mut writer = XlsxWriter::new();
4950
writer.add_rows(rows, options)?;
50-
extract_donor(writer.save_to_bytes()?, rows.len())
51+
extract_donor(
52+
writer.save_insert_donor_to_bytes()?,
53+
rows.len(),
54+
options.sheet_visibility(options.sheet_name()),
55+
)
5156
}
5257

5358
pub(crate) fn from_dynamic_with_schema(
@@ -57,7 +62,11 @@ impl DonorBuilder {
5762
) -> Result<DonorWorksheet> {
5863
let mut writer = XlsxWriter::new();
5964
writer.add_rows_with_schema(schema, rows, options)?;
60-
extract_donor(writer.save_to_bytes()?, rows.len())
65+
extract_donor(
66+
writer.save_insert_donor_to_bytes()?,
67+
rows.len(),
68+
options.sheet_visibility(options.sheet_name()),
69+
)
6170
}
6271

6372
pub(crate) fn from_serialized<T>(rows: &[T], options: &WriteOptions) -> Result<DonorWorksheet>
@@ -66,7 +75,11 @@ impl DonorBuilder {
6675
{
6776
let mut writer = XlsxWriter::new();
6877
writer.add_serialized(rows, options)?;
69-
extract_donor(writer.save_to_bytes()?, rows.len())
78+
extract_donor(
79+
writer.save_insert_donor_to_bytes()?,
80+
rows.len(),
81+
options.sheet_visibility(options.sheet_name()),
82+
)
7083
}
7184

7285
#[cfg(not(target_arch = "wasm32"))]
@@ -98,8 +111,12 @@ where
98111
let mut writer = XlsxWriter::new();
99112
writer.add_rows_iter_with_schema(schema, rows, row_count, options)?;
100113
let mut package = tempfile::NamedTempFile::new()?;
101-
writer.save_to_writer(package.as_file_mut())?;
102-
extract_donor_from_reader(package.reopen()?, row_count)
114+
writer.save_insert_donor_to_writer(package.as_file_mut())?;
115+
extract_donor_from_reader(
116+
package.reopen()?,
117+
row_count,
118+
options.sheet_visibility(options.sheet_name()),
119+
)
103120
}
104121

105122
#[cfg(not(target_arch = "wasm32"))]
@@ -166,11 +183,19 @@ fn spooled_rows(
166183
}))
167184
}
168185

169-
pub(super) fn extract_donor(bytes: Vec<u8>, data_row_count: usize) -> Result<DonorWorksheet> {
170-
extract_donor_from_reader(Cursor::new(bytes), data_row_count)
186+
pub(super) fn extract_donor(
187+
bytes: Vec<u8>,
188+
data_row_count: usize,
189+
visibility: SheetVisibility,
190+
) -> Result<DonorWorksheet> {
191+
extract_donor_from_reader(Cursor::new(bytes), data_row_count, visibility)
171192
}
172193

173-
fn extract_donor_from_reader<R>(mut source: R, data_row_count: usize) -> Result<DonorWorksheet>
194+
fn extract_donor_from_reader<R>(
195+
mut source: R,
196+
data_row_count: usize,
197+
visibility: SheetVisibility,
198+
) -> Result<DonorWorksheet>
174199
where
175200
R: Read + std::io::Seek,
176201
{
@@ -206,7 +231,14 @@ where
206231
};
207232
let worksheet_xml = inline_shared_strings(&worksheet_xml, &shared_strings)?;
208233

209-
Ok(DonorWorksheet { sheet_name, worksheet_xml, data_row_count, styles, local_defined_names })
234+
Ok(DonorWorksheet {
235+
sheet_name,
236+
visibility,
237+
worksheet_xml,
238+
data_row_count,
239+
styles,
240+
local_defined_names,
241+
})
210242
}
211243

212244
fn read_part<R>(archive: &mut ZipArchive<R>, path: &str) -> Result<Vec<u8>>

‎miniexcel/src/insert/rewrite.rs‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use zip::{CompressionMethod, ZipArchive, ZipWriter};
99
use super::donor::DonorWorksheet;
1010
use super::package::{DefinedName, PackageInventory, WorkbookSheet, WorksheetAllocation};
1111
use super::style::rebase_styles;
12-
use crate::{Error, Result, TargetRelationshipPolicy};
12+
use crate::{Error, Result, SheetVisibility, TargetRelationshipPolicy};
1313

1414
const CONTENT_TYPES_PATH: &str = "[Content_Types].xml";
1515
const WORKBOOK_PATH: &str = "xl/workbook.xml";
@@ -294,6 +294,7 @@ where
294294
let workbook_xml = append_workbook(
295295
&workbook_xml,
296296
&donor.sheet_name,
297+
donor.visibility,
297298
&allocation,
298299
inventory.sheets.len(),
299300
&donor.local_defined_names,
@@ -533,6 +534,7 @@ fn styles_path(inventory: &PackageInventory) -> Result<String> {
533534
fn append_workbook(
534535
xml: &[u8],
535536
sheet_name: &str,
537+
visibility: SheetVisibility,
536538
allocation: &WorksheetAllocation,
537539
local_sheet_id: usize,
538540
local_defined_names: &[DefinedName],
@@ -567,6 +569,7 @@ fn append_workbook(
567569
write_new_sheet(
568570
&mut writer,
569571
sheet_name,
572+
visibility,
570573
allocation,
571574
&prefix,
572575
&relationship_prefix,
@@ -1122,6 +1125,7 @@ where
11221125
fn write_new_sheet(
11231126
writer: &mut Writer<Vec<u8>>,
11241127
sheet_name: &str,
1128+
visibility: SheetVisibility,
11251129
allocation: &WorksheetAllocation,
11261130
element_prefix: &str,
11271131
relationship_prefix: &str,
@@ -1131,6 +1135,11 @@ fn write_new_sheet(
11311135
let relationship_id_name = qualify(relationship_prefix, "id");
11321136
sheet.push_attribute(("name", sheet_name));
11331137
sheet.push_attribute(("sheetId", sheet_id.as_str()));
1138+
match visibility {
1139+
SheetVisibility::Visible => {}
1140+
SheetVisibility::Hidden => sheet.push_attribute(("state", "hidden")),
1141+
SheetVisibility::VeryHidden => sheet.push_attribute(("state", "veryHidden")),
1142+
}
11341143
sheet.push_attribute((relationship_id_name.as_str(), allocation.relationship_id.as_str()));
11351144
write_event(writer, Event::Empty(sheet))
11361145
}
@@ -1692,8 +1701,15 @@ mod tests {
16921701
formula: "'New Data'!$A$1:$A$2".to_owned(),
16931702
};
16941703
let workbook = br#"<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="A" sheetId="1" r:id="rId1"/></sheets><externalReferences><externalReference r:id="rId4"/></externalReferences><calcPr calcId="7"/></workbook>"#;
1695-
let patched =
1696-
append_workbook(workbook, "New Data", &allocation, 1, &[defined_name]).unwrap();
1704+
let patched = append_workbook(
1705+
workbook,
1706+
"New Data",
1707+
SheetVisibility::Visible,
1708+
&allocation,
1709+
1,
1710+
&[defined_name],
1711+
)
1712+
.unwrap();
16971713
let text = String::from_utf8(patched).unwrap();
16981714
assert!(text.contains("<definedNames><definedName"));
16991715
assert!(text.find("</sheets>").unwrap() < text.find("<definedNames>").unwrap());
@@ -1705,7 +1721,15 @@ mod tests {
17051721

17061722
let prefixed_workbook = br#"<x:workbook xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:q="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><x:sheets><x:sheet name="A" sheetId="1" q:id="rId1"/></x:sheets><x:calcPr calcId="7"/></x:workbook>"#;
17071723
let prefixed = String::from_utf8(
1708-
append_workbook(prefixed_workbook, "New Data", &allocation, 1, &[]).unwrap(),
1724+
append_workbook(
1725+
prefixed_workbook,
1726+
"New Data",
1727+
SheetVisibility::Visible,
1728+
&allocation,
1729+
1,
1730+
&[],
1731+
)
1732+
.unwrap(),
17091733
)
17101734
.unwrap();
17111735
assert!(prefixed.contains("<x:sheet name=\"New Data\" sheetId=\"5\" q:id=\"rId8\"/>"));

‎miniexcel/src/insert/style.rs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,7 @@ mod tests {
746746
use super::*;
747747
use crate::insert::donor::{DonorBuilder, extract_donor};
748748
use crate::writer::XlsxWriter;
749-
use crate::{CellValue, DynamicRow, MiniExcel, ReadOptions, WriteOptions};
749+
use crate::{CellValue, DynamicRow, MiniExcel, ReadOptions, SheetVisibility, WriteOptions};
750750

751751
#[derive(Serialize)]
752752
struct CustomFormatRow {
@@ -800,6 +800,7 @@ mod tests {
800800
let first = rebase_styles(target_styles().as_bytes(), &donor).unwrap();
801801
let donor_again = DonorWorksheet {
802802
sheet_name: donor.sheet_name.clone(),
803+
visibility: donor.visibility,
803804
worksheet_xml: donor.worksheet_xml.clone(),
804805
data_row_count: donor.data_row_count,
805806
styles: super::super::donor::DonorStyleModel {
@@ -919,7 +920,7 @@ mod tests {
919920
let mut writer = XlsxWriter::new();
920921
writer.add_rows(&[row], &options).unwrap();
921922
let package = writer.save_to_bytes().unwrap();
922-
let extracted = extract_donor(package.clone(), 1).unwrap();
923+
let extracted = extract_donor(package.clone(), 1, SheetVisibility::Visible).unwrap();
923924
assert_eq!(donor, extracted);
924925
(donor, package)
925926
}

‎miniexcel/src/writer.rs‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ impl XlsxWriter {
9393
}
9494

9595
pub(crate) fn save(&mut self, path: impl AsRef<Path>, overwrite_file: bool) -> Result<()> {
96-
self.validate_workbook()?;
96+
self.validate_workbook(true)?;
9797
let file = OpenOptions::new()
9898
.write(true)
9999
.create(true)
@@ -105,15 +105,30 @@ impl XlsxWriter {
105105
}
106106

107107
pub(crate) fn save_to_bytes(&mut self) -> Result<Vec<u8>> {
108-
self.validate_workbook()?;
108+
self.validate_workbook(true)?;
109+
Ok(self.workbook.save_to_buffer()?)
110+
}
111+
112+
pub(crate) fn save_insert_donor_to_bytes(&mut self) -> Result<Vec<u8>> {
113+
self.validate_workbook(false)?;
109114
Ok(self.workbook.save_to_buffer()?)
110115
}
111116

112117
pub(crate) fn save_to_writer<W>(&mut self, writer: &mut W) -> Result<()>
113118
where
114119
W: Write + Send,
115120
{
116-
self.validate_workbook()?;
121+
self.validate_workbook(true)?;
122+
self.workbook.save_to_writer(writer)?;
123+
Ok(())
124+
}
125+
126+
#[cfg(not(target_arch = "wasm32"))]
127+
pub(crate) fn save_insert_donor_to_writer<W>(&mut self, writer: &mut W) -> Result<()>
128+
where
129+
W: Write + Send,
130+
{
131+
self.validate_workbook(false)?;
117132
self.workbook.save_to_writer(writer)?;
118133
Ok(())
119134
}
@@ -193,13 +208,13 @@ impl XlsxWriter {
193208
self.sheet_names.insert(normalized_name);
194209
}
195210

196-
fn validate_workbook(&self) -> Result<()> {
211+
fn validate_workbook(&self, require_visible_worksheet: bool) -> Result<()> {
197212
if let Some(name) =
198213
self.requested_visibilities.difference(&self.matched_visibilities).next()
199214
{
200215
return Err(Error::unknown_sheet_visibility(name));
201216
}
202-
if self.visible_worksheets == 0 {
217+
if require_visible_worksheet && self.visible_worksheets == 0 {
203218
return Err(Error::no_visible_worksheets());
204219
}
205220
Ok(())
@@ -381,6 +396,15 @@ fn validate_auto_width_options(options: &WriteOptions) -> Result<()> {
381396

382397
#[cfg(not(target_arch = "wasm32"))]
383398
pub(crate) fn validate_single_sheet_options(options: &WriteOptions) -> Result<()> {
399+
validate_insert_sheet_options(options)?;
400+
if options.sheet_visibility(options.sheet_name()) != SheetVisibility::Visible {
401+
return Err(Error::no_visible_worksheets());
402+
}
403+
Ok(())
404+
}
405+
406+
#[cfg(not(target_arch = "wasm32"))]
407+
pub(crate) fn validate_insert_sheet_options(options: &WriteOptions) -> Result<()> {
384408
validate_sheet_name(options.sheet_name(), &HashSet::new())?;
385409
validate_auto_width_options(options)?;
386410
validate_column_widths(options)?;
@@ -391,9 +415,6 @@ pub(crate) fn validate_single_sheet_options(options: &WriteOptions) -> Result<()
391415
{
392416
return Err(Error::unknown_sheet_visibility(name));
393417
}
394-
if options.sheet_visibility(options.sheet_name()) != SheetVisibility::Visible {
395-
return Err(Error::no_visible_worksheets());
396-
}
397418
Ok(())
398419
}
399420

0 commit comments

Comments
 (0)