Skip to content

Commit e616c76

Browse files
committed
Add preservation-safe XLSX worksheet package rewriting
1 parent 746bb9a commit e616c76

7 files changed

Lines changed: 954 additions & 19 deletions

File tree

docs/insert-existing-workbook-plan.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -218,20 +218,22 @@ Acceptance:
218218

219219
Depends on Tasks 1-3.
220220

221-
- [ ] Raw-copy every unchanged ZIP entry with its compression and metadata.
222-
- [ ] Structurally append one `<sheet>` to `xl/workbook.xml` without replacing workbook views, properties, defined names, calculation settings, or extension lists.
223-
- [ ] Append one worksheet relationship to `xl/_rels/workbook.xml.rels` without changing existing IDs or non-sheet relationships.
224-
- [ ] Add the worksheet override to `[Content_Types].xml` only when missing.
225-
- [ ] Add or update the local `_xlnm._FilterDatabase` defined name when AutoFilter is enabled.
226-
- [ ] Write the rebased worksheet to its collision-free target.
227-
- [ ] Keep `sharedStrings.xml`, untouched worksheet relationships, tables, drawings, comments, external links, custom XML, themes, and document properties byte-for-byte unchanged.
221+
- [x] Raw-copy every unchanged ZIP entry with its compression and metadata.
222+
- [x] Structurally append one `<sheet>` to `xl/workbook.xml` without replacing workbook views, properties, defined names, calculation settings, or extension lists.
223+
- [x] Append one worksheet relationship to `xl/_rels/workbook.xml.rels` without changing existing IDs or non-sheet relationships.
224+
- [x] Add the worksheet override to `[Content_Types].xml` only when missing.
225+
- [x] Add or update the local `_xlnm._FilterDatabase` defined name when AutoFilter is enabled.
226+
- [x] Write the rebased worksheet to its collision-free target.
227+
- [x] Keep `sharedStrings.xml`, untouched worksheet relationships, tables, drawings, comments, external links, custom XML, themes, and document properties byte-for-byte unchanged.
228+
229+
Completed on 2026-08-24. Focused test: `cargo +1.85.0 test -p miniexcel insert::rewrite::tests --lib --locked`.
228230

229231
Acceptance:
230232

231233
- New sheet appends in workbook order and does not change the active sheet.
232234
- Package inventory differs only in the expected control parts, style additions, and new worksheet.
233235
- Existing formulas and cached values remain unchanged.
234-
- Focused command: `cargo +1.85.0 test -p miniexcel --test insert append_package --locked`.
236+
- Focused command: `cargo +1.85.0 test -p miniexcel insert::rewrite::tests --lib --locked`.
235237

236238
### Task 5: Atomic Path Commit
237239

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

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -218,20 +218,22 @@ Donor-workbook 方案复用经过测试的 `XlsxWriter`,避免重复实现 ser
218218

219219
依赖 Task 1-3。
220220

221-
- [ ] Raw-copy 每个未变 ZIP entry,并保留 compression 与 metadata。
222-
- [ ] 结构化 append `xl/workbook.xml` 的一个 `<sheet>`,不替换 workbook view、property、defined name、calculation setting 或 extension list。
223-
- [ ]`xl/_rels/workbook.xml.rels` append 一个 worksheet relationship,不改变现有 ID 或非 sheet relationship。
224-
- [ ] 仅在缺失时向 `[Content_Types].xml` 增加 worksheet override。
225-
- [ ] 启用 AutoFilter 时增加或更新 local `_xlnm._FilterDatabase` defined name。
226-
- [ ] 将 rebased worksheet 写到无冲突 target。
227-
- [ ] 保持 `sharedStrings.xml`、未变 worksheet relationship、table、drawing、comment、external link、custom XML、theme 和 document property 原始字节不变。
221+
- [x] Raw-copy 每个未变 ZIP entry,并保留 compression 与 metadata。
222+
- [x] 结构化 append `xl/workbook.xml` 的一个 `<sheet>`,不替换 workbook view、property、defined name、calculation setting 或 extension list。
223+
- [x]`xl/_rels/workbook.xml.rels` append 一个 worksheet relationship,不改变现有 ID 或非 sheet relationship。
224+
- [x] 仅在缺失时向 `[Content_Types].xml` 增加 worksheet override。
225+
- [x] 启用 AutoFilter 时增加或更新 local `_xlnm._FilterDatabase` defined name。
226+
- [x] 将 rebased worksheet 写到无冲突 target。
227+
- [x] 保持 `sharedStrings.xml`、未变 worksheet relationship、table、drawing、comment、external link、custom XML、theme 和 document property 原始字节不变。
228+
229+
已于 2026-08-24 完成。聚焦测试:`cargo +1.85.0 test -p miniexcel insert::rewrite::tests --lib --locked`
228230

229231
验收:
230232

231233
- 新 sheet 按 workbook 顺序 append,不改变 active sheet。
232234
- Package inventory 只在预期 control part、style addition 和新 worksheet 上有变化。
233235
- 现有 formula 与 cached value 不变。
234-
- 聚焦命令:`cargo +1.85.0 test -p miniexcel --test insert append_package --locked`
236+
- 聚焦命令:`cargo +1.85.0 test -p miniexcel insert::rewrite::tests --lib --locked`
235237

236238
### Task 5:Atomic Path Commit
237239

miniexcel/src/insert/donor.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pub(crate) struct DonorStyleModel {
2727

2828
#[derive(Clone, Debug, Eq, PartialEq)]
2929
pub(crate) struct DonorWorksheet {
30+
pub(crate) sheet_name: String,
3031
pub(crate) worksheet_xml: Vec<u8>,
3132
pub(crate) data_row_count: usize,
3233
pub(crate) styles: DonorStyleModel,
@@ -125,6 +126,7 @@ pub(super) fn extract_donor(bytes: Vec<u8>, data_row_count: usize) -> Result<Don
125126
inventory.sheets.len()
126127
)));
127128
}
129+
let sheet_name = inventory.sheets[0].name.clone();
128130
let worksheet_path = inventory.sheets[0].target.clone();
129131
let local_defined_names =
130132
inventory.defined_names.into_iter().filter(|name| name.local_sheet_id == Some(0)).collect();
@@ -148,7 +150,7 @@ pub(super) fn extract_donor(bytes: Vec<u8>, data_row_count: usize) -> Result<Don
148150
};
149151
let worksheet_xml = inline_shared_strings(&worksheet_xml, &shared_strings)?;
150152

151-
Ok(DonorWorksheet { worksheet_xml, data_row_count, styles, local_defined_names })
153+
Ok(DonorWorksheet { sheet_name, worksheet_xml, data_row_count, styles, local_defined_names })
152154
}
153155

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

miniexcel/src/insert/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22

33
pub(crate) mod donor;
44
pub(crate) mod package;
5+
pub(crate) mod rewrite;
56
pub(crate) mod style;

miniexcel/src/insert/package.rs

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ impl PackageInventory {
104104
let entry_names = inspect_entries(&mut archive)?;
105105
let content_types =
106106
parse_content_types(&read_control_part(&mut archive, CONTENT_TYPES_PATH)?)?;
107+
validate_content_type_uniqueness(&content_types)?;
107108
reject_unsupported_content_types(&content_types)?;
108109

109110
let mut relationships = Vec::new();
@@ -129,7 +130,7 @@ impl PackageInventory {
129130
let mut ids = BTreeSet::new();
130131
let mut targets = BTreeSet::new();
131132
for (index, sheet) in sheet_elements.into_iter().enumerate() {
132-
let normalized_name = sheet.name.to_lowercase();
133+
let normalized_name = normalize_sheet_name(&sheet.name);
133134
if !names.insert(normalized_name) {
134135
return Err(Error::unsafe_package(format!(
135136
"worksheet name '{}' is duplicated case-insensitively",
@@ -189,7 +190,8 @@ impl PackageInventory {
189190
}
190191

191192
pub(crate) fn find_sheet(&self, name: &str) -> Option<&WorkbookSheet> {
192-
self.sheets.iter().find(|sheet| sheet.name.eq_ignore_ascii_case(name))
193+
let normalized = normalize_sheet_name(name);
194+
self.sheets.iter().find(|sheet| normalize_sheet_name(&sheet.name) == normalized)
193195
}
194196

195197
pub(crate) fn ensure_sheet_absent(&self, name: &str) -> Result<()> {
@@ -485,6 +487,12 @@ fn parse_workbook(xml: &[u8]) -> Result<(Vec<SheetElement>, Vec<WorkbookView>, V
485487
})?,
486488
);
487489
}
490+
Event::GeneralRef(reference) if current_defined_name.is_some() => {
491+
append_xml_reference(
492+
&reference,
493+
&mut current_defined_name.as_mut().expect("defined-name state").formula,
494+
)?;
495+
}
488496
Event::End(event) if local_name(event.name().as_ref()) == b"definedName" => {
489497
defined_names.push(current_defined_name.take().ok_or_else(|| {
490498
Error::insert_package("definedName closing tag has no start tag")
@@ -519,6 +527,28 @@ fn reject_unsupported_content_types(content_types: &ContentTypes) -> Result<()>
519527
Ok(())
520528
}
521529

530+
fn validate_content_type_uniqueness(content_types: &ContentTypes) -> Result<()> {
531+
let mut extensions = BTreeSet::new();
532+
for entry in &content_types.defaults {
533+
if !extensions.insert(entry.extension.to_lowercase()) {
534+
return Err(Error::unsafe_package(format!(
535+
"content-type default extension '{}' is duplicated",
536+
entry.extension
537+
)));
538+
}
539+
}
540+
let mut part_names = BTreeSet::new();
541+
for entry in &content_types.overrides {
542+
if !part_names.insert(entry.part_name.to_lowercase()) {
543+
return Err(Error::unsafe_package(format!(
544+
"content-type override '{}' is duplicated",
545+
entry.part_name
546+
)));
547+
}
548+
}
549+
Ok(())
550+
}
551+
522552
fn reject_unsupported_relationships(relationships: &[PackageRelationship]) -> Result<()> {
523553
for relationship in relationships {
524554
if let Some("vbaProject" | "vbaProjectSignature" | "digital-signature" | "signature") =
@@ -627,10 +657,39 @@ fn xml_attribute(event: &BytesStart<'_>, name: &[u8]) -> Result<Option<String>>
627657
Ok(None)
628658
}
629659

660+
fn append_xml_reference(
661+
reference: &quick_xml::events::BytesRef<'_>,
662+
target: &mut String,
663+
) -> Result<()> {
664+
let decoded = reference
665+
.decode()
666+
.map_err(|error| Error::insert_package(format!("invalid XML reference: {error}")))?;
667+
match decoded.as_ref() {
668+
"lt" => target.push('<'),
669+
"gt" => target.push('>'),
670+
"amp" => target.push('&'),
671+
"quot" => target.push('"'),
672+
"apos" => target.push('\''),
673+
_ => {
674+
if let Some(value) = reference
675+
.resolve_char_ref()
676+
.map_err(|error| Error::insert_package(format!("invalid XML reference: {error}")))?
677+
{
678+
target.push(value);
679+
}
680+
}
681+
}
682+
Ok(())
683+
}
684+
630685
fn local_name(name: &[u8]) -> &[u8] {
631686
name.rsplit(|byte| *byte == b':').next().unwrap_or(name)
632687
}
633688

689+
fn normalize_sheet_name(name: &str) -> String {
690+
name.to_lowercase()
691+
}
692+
634693
#[cfg(test)]
635694
mod tests {
636695
use std::io::{Cursor, Write};
@@ -749,6 +808,34 @@ mod tests {
749808
]);
750809
assert!(PackageInventory::inspect(Cursor::new(macro_package)).is_err());
751810

811+
let duplicate_types = TYPES.replace(
812+
"</Types>",
813+
"<Default Extension=\"XML\" ContentType=\"application/xml\"/></Types>",
814+
);
815+
let duplicate_types_package = zip_entries(&[
816+
(CONTENT_TYPES_PATH, &duplicate_types),
817+
(WORKBOOK_PATH, WORKBOOK),
818+
(WORKBOOK_RELS_PATH, WORKBOOK_RELS),
819+
("xl/worksheets/data.xml", "<worksheet/>"),
820+
("xl/worksheets/sheet1.xml", "<worksheet/>"),
821+
("xl/worksheets/sheet3.xml", "<worksheet/>"),
822+
]);
823+
assert!(PackageInventory::inspect(Cursor::new(duplicate_types_package)).is_err());
824+
825+
let duplicate_override_types = TYPES.replace(
826+
"</Types>",
827+
"<Override PartName=\"/XL/WORKBOOK.XML\" ContentType=\"application/xml\"/></Types>",
828+
);
829+
let duplicate_override_package = zip_entries(&[
830+
(CONTENT_TYPES_PATH, &duplicate_override_types),
831+
(WORKBOOK_PATH, WORKBOOK),
832+
(WORKBOOK_RELS_PATH, WORKBOOK_RELS),
833+
("xl/worksheets/data.xml", "<worksheet/>"),
834+
("xl/worksheets/sheet1.xml", "<worksheet/>"),
835+
("xl/worksheets/sheet3.xml", "<worksheet/>"),
836+
]);
837+
assert!(PackageInventory::inspect(Cursor::new(duplicate_override_package)).is_err());
838+
752839
let malformed_rels = WORKBOOK_RELS.replace("Id=\"rId9\"", "Id=\"rId2\"");
753840
let malformed = zip_entries(&[
754841
(CONTENT_TYPES_PATH, TYPES),

0 commit comments

Comments
 (0)