From a19cf6753bbcc10c237195d6e41bab72435afdcd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 22:09:08 -0500 Subject: [PATCH] craft-xlsx: tables (ListObjects) read and written back (#1062) * craft-xlsx: read parts with canonical prefixes so kept trees can be written back The part layer read every part by local name, so extLst children (x14:, xr:, xm:), mc:AlternateContent and r:id lost their namespaces and could not be written back. Parts are now read with one fixed prefix per namespace (the main namespace unprefixed), mc:Ignorable and mc:Choice/@Requires are rewritten to match, and ns::declare prepares a kept tree for writing. craft-xml: a namespace listed with the empty prefix reads unprefixed. * craft-xlsx: write SpreadsheetML workbooks (Document read and write) Document holds a whole workbook: the workbook part, shared strings, styles, each worksheet's rows and the rest of its part, and every other part kept as bytes. write regenerates the workbook part, worksheets, shared strings and styles from the model, keeps unknown children in place (schema order for regenerated ones), keeps part names and relationship ids, and writes kept parts unchanged. Writers: Styles::to_xml and to_el for each table entry, StringItem::to_el (phonetic runs checked as on reading), SheetPart::to_xml with rows streamed, Workbook::to_el, XlsxKind. Row and DefinedName keep their other attributes; CellStyle reads iLevel. craft-opc: LazyPackage::canonical gives a part's stored name. * craft-xlsx: typed worksheet features and comments, written back SheetPart::features lifts sheet views, print setup, data validation, conditional formats, hyperlinks and the legacy drawing into types that mirror their elements (optional attributes, unnamed ones kept) and writes them in schema order. Comments come from the comments part, note shapes from the VML drawing; a sheet that gains comments without a VML drawing gets one. A hyperlink to a new URL gets its own relationship. craft-sheet: Range derives Default. * craft-xlsx: drawings, charts and the theme on craft-drawingml Drawing reads a sheet's xdr:wsDr into anchors with typed placements and objects kept as written; Anchor gives the object's kind, properties, chart or image relationship and xfrm. Package::drawing, Package::chart (c: and cx: parts through craft-drawingml) and Package::theme. Document worksheets hold their drawing part, regenerated with its relationships kept by id; add_chart and add_picture add new chart parts (chartEx behind mc:AlternateContent) and images with their anchors. More drawing namespaces get prefixes (a16, asvg, aink, am3d, cx1-cx4, dgm). * craft-xlsx: tables (ListObjects) read and written back Table mirrors CT_Table: every attribute, columns with calculated and totals formulas, the style info, autoFilter/sortState/extensions kept as written. Package::tables reads a sheet's table parts; Document worksheets hold them as TableParts, regenerated with their relationships and tableParts entries. Part names a document keeps are reserved before new parts are named, so a new part never takes a name a later sheet keeps. --- crates/xlsx/README.md | 8 ++ crates/xlsx/src/document.rs | 61 +++++++++++--- crates/xlsx/src/features.rs | 164 ++++++++++++++++++++++++++++++++++++ crates/xlsx/src/lib.rs | 5 +- crates/xlsx/src/package.rs | 18 +++- crates/xlsx/tests/write.rs | 62 +++++++++++++- 6 files changed, 301 insertions(+), 17 deletions(-) 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() };