diff --git a/crates/xlsx/README.md b/crates/xlsx/README.md index d8ea357d3..3541dcb26 100644 --- a/crates/xlsx/README.md +++ b/crates/xlsx/README.md @@ -56,6 +56,14 @@ or image relationship and its `a:xfrm`, and `craft_drawingml::read` reads its fi (a `chartEx` part behind `mc:AlternateContent` for the Office 2016 types) or an image, with its anchor. Chart parts and media are kept parts. +Tables: `Package::tables` reads a sheet's table parts (in `tableParts` order) into `Table` +(every `CT_Table` attribute, `TableColumn`s with calculated and totals formulas, +`TableStyleInfo`; `autoFilter`, `sortState` and extensions kept as written). A `Document` +worksheet holds them as `TablePart`s, regenerated on writing with their relationships and +`tableParts` entries; a removed table takes both with it. Every part name a document keeps is +reserved before new parts are named, so a new part never takes a name another sheet's part +keeps. + The parts this crate writes use the transitional namespaces; a strict file's kept parts stay as they were. A binary workbook is not written. diff --git a/crates/xlsx/src/document.rs b/crates/xlsx/src/document.rs index 829e242a2..dc75a9728 100644 --- a/crates/xlsx/src/document.rs +++ b/crates/xlsx/src/document.rs @@ -6,7 +6,7 @@ use craft_opc::{ContentTypes, PackageOut, RT_BASE, Rel, RelOut, Rels, RelsOut, r use craft_drawingml::chart::Chart; use crate::drawing::{Anchor, Drawing, Item, Placement}; -use crate::features::{Comments, NoteShape, notes_vml}; +use crate::features::{Comments, NoteShape, Table, notes_vml}; use crate::sheet::{CellValue, Row, SheetPart}; use crate::strings::StringItem; use crate::styles::Styles; @@ -18,6 +18,7 @@ const CT_STRINGS: &str = "application/vnd.openxmlformats-officedocument.spreadsh const CT_STYLES: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"; const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml"; const CT_VML: &str = "application/vnd.openxmlformats-officedocument.vmlDrawing"; +const CT_TABLE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"; const CT_DRAWING: &str = "application/vnd.openxmlformats-officedocument.drawing+xml"; const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml"; @@ -86,6 +87,16 @@ pub struct Worksheet { pub notes: Vec, /// The drawing part, written from here. `None` removes it. pub drawing: Option, + /// The table parts, written from here, in the order of the sheet's `tableParts`. + pub tables: Vec, +} + +/// A table part. Its relationships (a query table) are kept parts. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TablePart { + /// The part's name; a new name is chosen on writing when empty. + pub part: String, + pub table: Table, } /// A worksheet's drawing part and its relationships (to charts and pictures, kept with their @@ -185,7 +196,8 @@ impl Document { let comments = p.comments(&part)?; let notes = if comments.is_some() { p.note_shapes(&part)? } else { Vec::new() }; let drawing = p.drawing(&part)?.map(|(name, drawing, rels)| DrawingPart { part: name, drawing, rels: rels.list }); - worksheets.push(Some(Worksheet { rows, part, comments, notes, drawing })); + let tables = p.tables(&part)?.into_iter().map(|(part, table)| TablePart { part, table }).collect(); + worksheets.push(Some(Worksheet { rows, part, comments, notes, drawing, tables })); } Err(Error::TooLarge(m)) => return Err(Error::TooLarge(m)), Err(e) => { @@ -206,6 +218,7 @@ impl Document { made.push(d.part.clone()); made.push(rels_path(&d.part)); } + made.extend(ws.tables.iter().map(|t| t.part.clone())); } made.extend(workbook_rels.list.iter().filter(|r| !r.external && regenerated(r)).map(|r| r.target.clone())); let opc = p.opc(); @@ -310,7 +323,14 @@ impl Document { } let mut workbook = self.workbook.clone(); let mut sheet_parts = Vec::new(); + // Every name a part keeps is taken before any new part is named, so a new one never + // takes the name of a part written later. let mut taken: Vec = self.parts.iter().map(|p| p.name.to_ascii_lowercase()).collect(); + for ws in self.worksheets.iter().flatten() { + let comments = ws.comments.as_ref().and(ws.part.rels.first("comments")).map(|r| r.target.clone()); + let own = [ws.part.part.clone()].into_iter().chain(comments).chain(ws.drawing.iter().map(|d| d.part.clone())); + taken.extend(own.chain(ws.tables.iter().map(|t| t.part.clone())).filter(|n| !n.is_empty()).map(|n| n.to_ascii_lowercase())); + } for (i, (entry, ws)) in workbook.sheets.iter_mut().zip(&self.worksheets).enumerate() { let Some(ws) = ws else { if rels.items.iter().any(|r| r.id == entry.rel_id) { @@ -319,7 +339,7 @@ impl Document { return Err(Error::Invalid(format!("sheet \"{}\" has neither a worksheet nor a kept part", entry.name))); }; let mut name = ws.part.part.clone(); - if name.is_empty() || taken.contains(&name.to_ascii_lowercase()) { + if name.is_empty() { name = fresh(&taken, |n| format!("xl/worksheets/sheet{}.xml", i + n)); } taken.push(name.to_ascii_lowercase()); @@ -357,8 +377,9 @@ impl Document { out.rels("", &package); } let mut blocks = vml_blocks(&self.parts); + let kept_names: Vec = self.parts.iter().map(|p| p.name.to_ascii_lowercase()).collect(); for (name, ws) in &sheet_parts { - write_sheet(&mut out, name, ws, &mut taken, &mut blocks); + write_sheet(&mut out, name, ws, &kept_names, &mut taken, &mut blocks); } if let Some(name) = &strings { let count: usize = @@ -385,13 +406,15 @@ impl Document { /// A worksheet, its relationships, and its comments with a VML drawing to show them when it has /// none. A hyperlink to a URL its relationship does not lead to gets a relationship of its own. -fn write_sheet(out: &mut PackageOut, name: &str, ws: &Worksheet, taken: &mut Vec, blocks: &mut Vec) { +fn write_sheet(out: &mut PackageOut, name: &str, ws: &Worksheet, kept_names: &[String], taken: &mut Vec, blocks: &mut Vec) { let mut part = ws.part.clone(); let mut rels = RelsOut::default(); + let kept_part = |r: &Rel| kept_names.contains(&r.target.to_ascii_lowercase()); // A comments or drawing relationship stays when this writes that part, or the part is kept. let stays = |r: &&Rel| match r.kind() { "comments" => ws.comments.is_some(), - "drawing" => ws.drawing.as_ref().map_or(taken.contains(&r.target.to_ascii_lowercase()), |d| d.part.eq_ignore_ascii_case(&r.target)), + "drawing" => ws.drawing.as_ref().map_or(kept_part(r), |d| d.part.eq_ignore_ascii_case(&r.target)), + "table" => ws.tables.iter().any(|t| t.part.eq_ignore_ascii_case(&r.target)) || kept_part(r), _ => true, }; rels.items.extend(ws.part.rels.list.iter().filter(stays).map(kept)); @@ -399,11 +422,7 @@ fn write_sheet(out: &mut PackageOut, name: &str, ws: &Worksheet, taken: &mut Vec part.features.drawing = None; } if let Some(d) = &ws.drawing { - let target = if d.part.is_empty() || taken.contains(&d.part.to_ascii_lowercase()) { - fresh(taken, |n| format!("xl/drawings/drawing{n}.xml")) - } else { - d.part.clone() - }; + let target = if d.part.is_empty() { fresh(taken, |n| format!("xl/drawings/drawing{n}.xml")) } else { d.part.clone() }; taken.push(target.to_ascii_lowercase()); let id = match ws.part.rels.list.iter().find(|r| r.kind() == "drawing" && r.target.eq_ignore_ascii_case(&target)) { Some(r) => r.id.clone(), @@ -457,6 +476,26 @@ fn write_sheet(out: &mut PackageOut, name: &str, ws: &Worksheet, taken: &mut Vec out.binary(&vml, CT_VML, notes_vml(&c.comments, &ws.notes, block)); } } + let mut table_ids = Vec::new(); + for t in &ws.tables { + let target = if t.part.is_empty() { fresh(taken, |n| format!("xl/tables/table{n}.xml")) } else { t.part.clone() }; + taken.push(target.to_ascii_lowercase()); + let id = match ws.part.rels.list.iter().find(|r| r.kind() == "table" && r.target.eq_ignore_ascii_case(&target)) { + Some(r) => r.id.clone(), + None => { + let id = fresh_id(&rels); + rels.items.push(RelOut { id: id.clone(), ty: format!("{RT_BASE}table"), target: relative(name, &target), external: false }); + id + } + }; + table_ids.push(id); + out.xml_part(&target, CT_TABLE, t.table.to_xml()); + } + // Tables kept as parts (unreadable ones) keep their place after those written. + let kept_tables = + part.features.table_parts.iter().filter(|id| !table_ids.contains(id) && rels.items.iter().any(|r| r.id == **id && r.ty.ends_with("/table"))); + table_ids.extend(kept_tables.cloned().collect::>()); + part.features.table_parts = table_ids; out.xml_part(name, CT_WORKSHEET, part.to_xml(&ws.rows)); out.rels(name, &rels); } diff --git a/crates/xlsx/src/features.rs b/crates/xlsx/src/features.rs index db020ab49..a23ef6118 100644 --- a/crates/xlsx/src/features.rs +++ b/crates/xlsx/src/features.rs @@ -651,6 +651,8 @@ pub struct Features { pub legacy_drawing: Option, /// `drawing/@r:id`: the drawing part (shapes, pictures, charts). pub drawing: Option, + /// `tableParts/tablePart/@r:id`: the sheet's table parts, in order. + pub table_parts: Vec, } impl Features { @@ -677,6 +679,10 @@ impl Features { .unwrap_or_default(), legacy_drawing: root.child("legacyDrawing").and_then(|l| l.attr_or_plain("r:id")).map(str::to_string), drawing: root.child("drawing").and_then(|l| l.attr_or_plain("r:id")).map(str::to_string), + table_parts: root + .child("tableParts") + .map(|t| t.children("tablePart").filter_map(|p| p.attr_or_plain("r:id")).map(str::to_string).collect()) + .unwrap_or_default(), } } @@ -713,6 +719,14 @@ impl Features { ("colBreaks", Vec::from_iter(breaks_el("colBreaks", &p.col_breaks))), ("legacyDrawing", Vec::from_iter(self.legacy_drawing.as_ref().map(|id| El::new("legacyDrawing").with_attr("r:id", id)))), ("drawing", Vec::from_iter(self.drawing.as_ref().map(|id| El::new("drawing").with_attr("r:id", id)))), + ( + "tableParts", + Vec::from_iter((!self.table_parts.is_empty()).then(|| { + let mut e = El::new("tableParts").with_attr("count", self.table_parts.len()); + self.table_parts.iter().for_each(|id| e.push(El::new("tablePart").with_attr("r:id", id))); + e + })), + ), ] } } @@ -921,3 +935,153 @@ mod tests { assert_eq!(parse_sqref(" A1 B2:C3 bad 4:4").len(), 3); } } + +// ------------------------------------------------------------------------------- tables + +element! { + /// ``. + pub struct TableColumn { + id: u32 = "id", + unique_name: String = "uniqueName", + name: String = "name", + /// `none`, `sum`, `min`, `max`, `average`, `count`, `countNums`, `stdDev`, `var`, `custom`. + totals_row_function: String = "totalsRowFunction", + totals_row_label: String = "totalsRowLabel", + query_table_field_id: u32 = "queryTableFieldId", + header_row_dxf_id: u32 = "headerRowDxfId", + data_dxf_id: u32 = "dataDxfId", + totals_row_dxf_id: u32 = "totalsRowDxfId", + header_row_cell_style: String = "headerRowCellStyle", + data_cell_style: String = "dataCellStyle", + totals_row_cell_style: String = "totalsRowCellStyle", + } + + { + /// `calculatedColumnFormula`, as written, and whether it is an array formula. + calculated_formula: Option<(String, bool)>, + /// `totalsRowFormula` (for `custom`), and whether it is an array formula. + totals_row_formula: Option<(String, bool)>, + /// `xmlColumnPr` and `extLst`, as written. + other: Vec, + } +} + +element! { + /// ``. + pub struct TableStyleInfo { + name: String = "name", + show_first_column: bool = "showFirstColumn", + show_last_column: bool = "showLastColumn", + show_row_stripes: bool = "showRowStripes", + show_column_stripes: bool = "showColumnStripes", + } +} + +element! { + /// A table (``, 18.5): a named range with a header row, columns and a style. + pub struct Table { + id: u32 = "id", + name: String = "name", + display_name: String = "displayName", + comment: String = "comment", + /// `worksheet`, `xml`, `queryTable`. + table_type: String = "tableType", + header_row_count: u32 = "headerRowCount", + insert_row: bool = "insertRow", + insert_row_shift: bool = "insertRowShift", + totals_row_count: u32 = "totalsRowCount", + totals_row_shown: bool = "totalsRowShown", + published: bool = "published", + header_row_dxf_id: u32 = "headerRowDxfId", + data_dxf_id: u32 = "dataDxfId", + totals_row_dxf_id: u32 = "totalsRowDxfId", + header_row_border_dxf_id: u32 = "headerRowBorderDxfId", + table_border_dxf_id: u32 = "tableBorderDxfId", + totals_row_border_dxf_id: u32 = "totalsRowBorderDxfId", + header_row_cell_style: String = "headerRowCellStyle", + data_cell_style: String = "dataCellStyle", + totals_row_cell_style: String = "totalsRowCellStyle", + connection_id: u32 = "connectionId", + } + + { + range: Range, + /// `autoFilter` and `sortState`, as written (they may hold their own extensions). + auto_filter: Option, + sort_state: Option, + columns: Vec, + style: Option, + ext: Option, + } +} + +impl Table { + /// A table part read with [`crate::ns::CANONICAL`]. `None` without a readable `ref`. + pub fn read(t: &El) -> Option
{ + let formula = |c: &El, n: &str| c.child(n).map(|f| (f.text().into_owned(), f.flag("array", false))); + let columns = t + .child("tableColumns") + .map(|cs| { + cs.children("tableColumn") + .map(|c| TableColumn { + calculated_formula: formula(c, "calculatedColumnFormula"), + totals_row_formula: formula(c, "totalsRowFormula"), + other: kids(c, &["calculatedColumnFormula", "totalsRowFormula"]), + ..TableColumn::read_attrs(c) + }) + .collect() + }) + .unwrap_or_default(); + Some(Table { + range: Range::parse(t.attr("ref")?)?, + auto_filter: t.child("autoFilter").cloned(), + sort_state: t.child("sortState").cloned(), + columns, + style: t.child("tableStyleInfo").map(TableStyleInfo::read_attrs), + ext: t.child("extLst").cloned(), + ..Table::read_attrs(t) + }) + } + + /// The table part. + pub fn to_xml(&self) -> Vec { + let mut e = El::new("table"); + self.write_attrs(&mut e); + e.attrs.insert(0, ("ref".into(), self.range.a1())); + // `id`, `name` and `displayName` lead, as Excel writes them. + e.attrs.sort_by_key(|(k, _)| match k.as_str() { + "id" => 0, + "name" => 1, + "displayName" => 2, + "ref" => 3, + _ => 4, + }); + for x in [&self.auto_filter, &self.sort_state].into_iter().flatten() { + e.push(x.clone()); + } + let mut cols = El::new("tableColumns").with_attr("count", self.columns.len()); + for c in &self.columns { + let mut ce = El::new("tableColumn"); + c.write_attrs(&mut ce); + for (n, f) in [("calculatedColumnFormula", &c.calculated_formula), ("totalsRowFormula", &c.totals_row_formula)] { + if let Some((text, array)) = f { + let fe = El::new(n).with_text(text.as_str()); + ce.push(if *array { fe.with_attr("array", 1) } else { fe }); + } + } + ce.children.extend(c.other.iter().cloned().map(Node::El)); + cols.push(ce); + } + e.push(cols); + if let Some(s) = &self.style { + let mut se = El::new("tableStyleInfo"); + s.write_attrs(&mut se); + e.push(se); + } + if let Some(x) = &self.ext { + e.push(x.clone()); + } + crate::ns::declare(&mut e); + let mut w = craft_xml::Writer::document(); + w.tree(&e); + w.into_bytes() + } +} diff --git a/crates/xlsx/src/lib.rs b/crates/xlsx/src/lib.rs index 039720687..ae94c376b 100644 --- a/crates/xlsx/src/lib.rs +++ b/crates/xlsx/src/lib.rs @@ -68,11 +68,12 @@ pub use craft_sheet::{ BorderStyle, CellError, CellRef, DEFAULT_PALETTE, HAlign, MAX_COLS, MAX_ROWS, Pattern, Range, Script, Underline, VAlign, Value, Visibility, builtin_format, builtin_format_id, quote_sheet, sheet_needs_quotes, }; -pub use document::{Document, DrawingPart, KeptPart, Worksheet, XlsxKind}; +pub use document::{Document, DrawingPart, KeptPart, TablePart, Worksheet, XlsxKind}; pub use drawing::{Anchor, Drawing, Item, Marker, ObjectKind, Placement}; pub use features::{ Break, CfRule, Cfvo, ColorScale, Comment, Comments, ConditionalFormatting, DataBar, DataValidation, DataValidations, Features, HeaderFooter, - Hyperlink, IconSet, NoteShape, PageMargins, PageSetup, Pane, Print, PrintOptions, Selection, SheetView, notes_vml, parse_sqref, range_ref, + Hyperlink, IconSet, NoteShape, PageMargins, PageSetup, Pane, Print, PrintOptions, Selection, SheetView, Table, TableColumn, TableStyleInfo, + notes_vml, parse_sqref, range_ref, }; pub use package::{Package, is_xlsb}; pub use sheet::{Cell, CellFormula, CellValue, Column, FormulaKind, Row, SheetPart, iso_date_serial}; diff --git a/crates/xlsx/src/package.rs b/crates/xlsx/src/package.rs index 569f1596f..5d415f813 100644 --- a/crates/xlsx/src/package.rs +++ b/crates/xlsx/src/package.rs @@ -7,7 +7,7 @@ use craft_drawingml::chart::Chart; use craft_drawingml::theme::Theme; use crate::drawing::Drawing; -use crate::features::{Comments, NoteShape}; +use crate::features::{Comments, NoteShape, Table}; use crate::sheet::{Row, RowReader, SheetPart}; use crate::strings::StringItem; use crate::styles::Styles; @@ -284,6 +284,22 @@ impl<'a> Package<'a> { Ok(Some((part, Drawing::read(&root), rels))) } + /// The tables of a worksheet read by [`Package::sheet`], in the order of its `tableParts`, + /// each with its part name. A table part that does not read is skipped with a warning. + pub fn tables(&mut self, sheet: &SheetPart) -> Result, Error> { + let mut out = Vec::new(); + for id in &sheet.features.table_parts { + let Some(rel) = sheet.rels.by_id(id).filter(|r| !r.external) else { continue }; + let part = rel.target.clone(); + match self.optional_xml(&part)?.map(|t| Table::read(&t)) { + Some(Some(t)) => out.push((part, t)), + Some(None) => self.warn(format!("skipped {part}: the table has no range")), + None => {} + } + } + Ok(out) + } + /// A chart part (`c:chartSpace`, or `cx:chartSpace` of an Office 2016 chart) through /// `craft-drawingml`. `None` when the part is missing or holds no chart it can read. pub fn chart(&mut self, part: &str) -> Result, Error> { diff --git a/crates/xlsx/tests/write.rs b/crates/xlsx/tests/write.rs index 2fea93ab0..bb7cc683b 100644 --- a/crates/xlsx/tests/write.rs +++ b/crates/xlsx/tests/write.rs @@ -31,11 +31,11 @@ fn excel_like() -> Vec { r#" 010.1SUM(C1:C3)0.300000000000000041C1*20.2#N/A inline 0.4 -0.25"a,b"&C&P x +0.25"a,b"&C&P x Data!C1:C3D1"# ); let sheet1_rels = format!( - r#""# + r#""# ); let styles = format!( r#" @@ -51,7 +51,7 @@ fn excel_like() -> Vec { let package_rels = format!( r#""# ); - let types = r#""#; + let types = r#""#; zip(&[ ("[Content_Types].xml", types.as_bytes()), ("_rels/.rels", package_rels.as_bytes()), @@ -71,6 +71,7 @@ fn excel_like() -> Vec { ("xl/sharedStrings.xml", strings.as_bytes()), ("xl/calcChain.xml", b""), ("xl/comments1.xml", COMMENTS.as_bytes()), + ("xl/tables/table1.xml", TABLE.as_bytes()), ("xl/drawings/vmlDrawing1.vml", VML.as_bytes()), ("xl/vbaProject.bin", &[0xD0, 0xCF, 0x11, 0xE0, 1, 2, 3]), ]) @@ -83,6 +84,8 @@ const DRAWING: &str = r#""#; const CHART: &str = r#"S1Data!$A$1:$A$2abData!$C$1:$C$212"#; +const TABLE: &str = r#"
Prices[[#This Row],[Flag]]*2
"#; + fn read(bytes: &[u8]) -> Document { Document::read(bytes, &Limits::default()).unwrap() } @@ -130,6 +133,7 @@ fn assert_same(a: &Document, b: &Document) { assert_eq!(x.part.features, y.part.features); assert_eq!(x.notes, y.notes); assert_eq!(x.drawing, y.drawing); + assert_eq!(x.tables, y.tables); assert_eq!( x.comments.as_ref().map(|c| (&c.authors, &c.comments, c.to_xml())), y.comments.as_ref().map(|c| (&c.authors, &c.comments, c.to_xml())) @@ -373,6 +377,58 @@ fn charts_and_pictures() { assert!(sheet.rels.first("drawing").is_none() && sheet.features.drawing.is_none()); } +#[test] +fn tables() { + let mut doc = read(&excel_like()); + let ws = doc.worksheets[0].clone().unwrap(); + assert_eq!(ws.part.features.table_parts, ["rId5"]); + let t = &ws.tables[0]; + assert_eq!( + (t.part.as_str(), t.table.name.as_deref(), t.table.range.a1(), t.table.totals_row_count), + ("xl/tables/table1.xml", Some("Prices"), "A1:C4".to_string(), Some(1)) + ); + assert_eq!(t.table.attrs, [("xr:uid".to_string(), "{T}".to_string())]); + let value = &t.table.columns[2]; + assert_eq!( + (value.totals_row_function.as_deref(), value.data_dxf_id, value.calculated_formula.clone()), + (Some("sum"), Some(0), Some(("Prices[[#This Row],[Flag]]*2".to_string(), false))) + ); + assert_eq!(t.table.style.as_ref().and_then(|s| s.show_row_stripes), Some(true)); + assert!(t.table.auto_filter.is_some()); + assert!(!doc.parts.iter().any(|p| p.name.contains("tables/"))); + + // A new table on the third sheet takes a name no other table has. + let mut new = t.table.clone(); + new.id = Some(2); + new.name = Some("Other".into()); + new.display_name = Some("Other".into()); + doc.worksheets[2].as_mut().unwrap().tables.push(craft_xlsx::TablePart { part: String::new(), table: new }); + let again = read(&doc.write().unwrap()); + assert_same(&doc_after(&doc), &again); + let t2 = again.worksheets[2].clone().unwrap().tables.remove(0); + assert_eq!((t2.part.as_str(), t2.table.name.as_deref()), ("xl/tables/table2.xml", Some("Other"))); + assert_eq!(again.worksheets[0].as_ref().unwrap().tables[0].table, t.table); + + // A table removed takes its relationship and its tableParts entry with it. + let mut doc = again; + doc.worksheets[0].as_mut().unwrap().tables.clear(); + let bytes = doc.write().unwrap(); + let mut p = Package::open(&bytes, &Limits::default()).unwrap(); + let wb = p.workbook().unwrap(); + let sheet = p.sheet(&wb.sheets[0], |_| Ok(())).unwrap(); + assert!(sheet.rels.first("table").is_none() && sheet.features.table_parts.is_empty() && sheet.root.child("tableParts").is_none()); + assert!(p.opc().read("xl/tables/table1.xml").unwrap().is_none()); + + // A new part on an earlier sheet never takes the name a later sheet's part keeps. + let mut doc = read(&excel_like()); + let kept = doc.worksheets[0].as_mut().unwrap().tables.remove(0); + doc.worksheets[2].as_mut().unwrap().tables.push(kept); + doc.worksheets[0].as_mut().unwrap().tables.push(craft_xlsx::TablePart { part: String::new(), table: t2.table.clone() }); + let again = read(&doc.write().unwrap()); + let names = |i: usize| again.worksheets[i].as_ref().unwrap().tables.iter().map(|t| t.part.clone()).collect::>(); + assert_eq!((names(0), names(2)), (vec!["xl/tables/table2.xml".to_string()], vec!["xl/tables/table1.xml".to_string()])); +} + #[test] fn a_new_workbook() { let cell = |at: &str, value| Cell { at: CellRef::parse(at).unwrap(), value, ..Cell::default() };