diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index bacdb06..196c57d 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -386,6 +386,8 @@ fn arrays_do_not_spill_in_tables() { last_col: false, filter_button: false, filter: vec![], + table_type: None, + connection_id: None, }); t.set("C4", "=SEQUENCE(2)"); t.set("E1", "=SEQUENCE(4)"); @@ -755,7 +757,14 @@ fn subtotal_skips_filtered_rows() { range: gridcraft_core::RangeRef::parse("A1:A7").unwrap(), header_row: true, totals_row: false, - columns: vec![gridcraft_model::TableColumn { name: "Amount".into(), totals: Default::default(), totals_label: None, formula: None }], + columns: vec![gridcraft_model::TableColumn { + name: "Amount".into(), + totals: Default::default(), + totals_label: None, + formula: None, + unique_name: None, + xml: None, + }], style: String::new(), banded_rows: false, banded_cols: false, @@ -763,6 +772,8 @@ fn subtotal_skips_filtered_rows() { last_col: false, filter_button: true, filter: crit(), + table_type: None, + connection_id: None, }); sh.autofilter = Some(AutoFilter { range: gridcraft_core::RangeRef::parse("C1:C7").unwrap(), criteria: crit() }); // Rows 3 and 5 are filtered out; row 9 (outside both filters) is hidden by hand. diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 62bc515..01e2ac6 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -838,7 +838,14 @@ fn table_resize(s: &mut Session, p: &Json) -> Result { for (i, h) in headers.iter().enumerate() { let name = if h.is_empty() { format!("Column{}", i + 1) } else { h.clone() }; let existing = t.columns.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).cloned(); - cols.push(existing.unwrap_or(TableColumn { name, totals: TotalsFn::None, totals_label: None, formula: None })); + cols.push(existing.unwrap_or(TableColumn { + name, + totals: TotalsFn::None, + totals_label: None, + formula: None, + unique_name: None, + xml: None, + })); } t.columns = cols; t.range = r; diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index 9795ea6..d25b367 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -386,7 +386,10 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { range, header_row: true, totals_row: false, - columns: headers.iter().map(|h| TableColumn { name: h.clone(), totals: TotalsFn::None, totals_label: None, formula: None }).collect(), + columns: headers + .iter() + .map(|h| TableColumn { name: h.clone(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }) + .collect(), style: "TableStyleMedium2".into(), banded_rows: true, banded_cols: false, @@ -394,6 +397,8 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { last_col: false, filter_button: true, filter: vec![], + table_type: None, + connection_id: None, }); let q = gridcraft_formula::quote_sheet(&sheet_name); let col_ref = |c: u32| format!("{q}!${0}$2:${0}${end_row}", col_letters(c)); diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 3199542..d878e44 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -319,7 +319,10 @@ fn insert_table(s: &mut Session, p: &Json) -> Result { range: r, header_row: true, totals_row: false, - columns: names.into_iter().map(|n| TableColumn { name: n, totals: TotalsFn::None, totals_label: None, formula: None }).collect(), + columns: names + .into_iter() + .map(|n| TableColumn { name: n, totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }) + .collect(), style, banded_rows: true, banded_cols: false, @@ -327,6 +330,8 @@ fn insert_table(s: &mut Session, p: &Json) -> Result { last_col: false, filter_button: true, filter: vec![], + table_type: None, + connection_id: None, }); cx.structural = true; Ok(json!({"table": name, "range": r.a1()})) diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 4dac63a..7870dee 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -1024,7 +1024,14 @@ fn shift_table_columns(t: &mut gridcraft_model::Table, at: u32, count: u32, inse break name; } }; - let col = gridcraft_model::TableColumn { name, totals: Default::default(), totals_label: None, formula: None }; + let col = gridcraft_model::TableColumn { + name, + totals: Default::default(), + totals_label: None, + formula: None, + unique_name: None, + xml: None, + }; t.columns.insert((idx + k).min(t.columns.len()), col); } } diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index 7e8e08b..b51f7bc 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -48,6 +48,23 @@ pub struct TableColumn { pub totals_label: Option, /// Calculated column formula. pub formula: Option, + /// `uniqueName`: the name XML maps know the column by. + #[serde(default)] + pub unique_name: Option, + /// Binding to an element of an XML map. It moves with the column and goes when the column is + /// deleted; inserted columns are unmapped. + #[serde(default)] + pub xml: Option, +} + +/// A table column's binding to an XML map (``). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct XmlColumnPr { + pub map_id: u32, + pub xpath: String, + /// `xmlDataType` as written in the file (`string`, `boolean`, `integer`…). + pub data_type: String, + pub denormalized: bool, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -70,6 +87,12 @@ pub struct Table { /// SUBTOTAL distinguishes. #[serde(default)] pub filter: Vec<(u32, FilterCriterion)>, + /// `tableType` when it isn't a plain worksheet table: `xml` for a table bound to an XML map. + #[serde(default)] + pub table_type: Option, + /// The connection (`xl/connections.xml`) an XML table is bound through. + #[serde(default)] + pub connection_id: Option, } impl Table { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 085b113..97b787e 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3408,6 +3408,8 @@ mod tests { last_col: false, filter_button: true, filter: vec![], + table_type: None, + connection_id: None, }; let mut sh = Sheet::new("S"); sh.tables.push(table(1, 0, 2)); diff --git a/crates/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index 8af5583..dd881c1 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -3,11 +3,11 @@ //! On read, the raw bytes of unmodelled parts are kept with their content type and their //! relationship graph, per owner: the package root (custom document properties, ribbon //! customisation, add-in task panes), the workbook (custom XML, links to external workbooks with -//! their ``, data connections) and each sheet (query tables, form controls, -//! ActiveX controls, OLE objects, the sheet background and printer settings, with the sheet's -//! ``, `` and `` XML, the `r:id` of `` and the legacy -//! VML shapes those controls need). The workbook also keeps its `` and the -//! calcFeatures extension, which Excel needs to trust the saved values. +//! their ``, data connections, XML maps) and each sheet (query tables, cells +//! mapped to XML elements, form controls, ActiveX controls, OLE objects, the sheet background and +//! printer settings, with the sheet's ``, `` and `` XML, the `r:id` +//! of `` and the legacy VML shapes those controls need). The workbook also keeps its +//! `` and the calcFeatures extension, which Excel needs to trust the saved values. //! //! On write, kept parts get fresh part names where needed and new relationship ids; the `r:id`s in //! kept XML are rewritten to match. Kept content is conservative: @@ -18,8 +18,8 @@ //! opened, followed or refreshed. A relative path to a linked workbook is rewritten when the //! workbook is saved into another folder ([`rebase_links`]), so it still names the same file. //! - Sheet content lives on the sheet: deleting the sheet drops it, Copy Sheet doesn't copy it. -//! Cell references inside kept parts (a control's linked cell, a query table's range) are kept -//! as they were, not adjusted to inserted or deleted rows. +//! Cell references inside kept parts (a control's linked cell, a query table's range, a cell +//! mapped to an XML element) are kept as they were, not adjusted to inserted or deleted rows. //! - Strict Open XML files are not passed through (their namespaces differ from what we write). //! - Sizes are bounded: per part and per workbook. @@ -81,9 +81,9 @@ const CT_VML: &str = "application/vnd.openxmlformats-officedocument.vmlDrawing"; /// Relationship kinds (last segment of the type) kept from the package root. const PACKAGE_ROOTS: &[&str] = &["custom-properties", "extensibility", "webextensiontaskpanes"]; /// Relationship kinds kept from the workbook without a workbook element. -const WORKBOOK_ROOTS: &[&str] = &["customXml", "connections"]; +const WORKBOOK_ROOTS: &[&str] = &["customXml", "connections", "xmlMaps"]; /// Relationship kinds kept from a sheet without a sheet element. -const SHEET_ROOTS: &[&str] = &["queryTable"]; +const SHEET_ROOTS: &[&str] = &["queryTable", "tableSingleCells"]; /// Sheet relationship kinds that mean the sheet XML may hold elements worth keeping. const SHEET_ELEMENT_KINDS: &[&str] = &["oleObject", "ctrlProp", "control", "image", "package", "printerSettings"]; /// Attributes of `` (CT_FileVersion). @@ -151,6 +151,7 @@ const OWN_DIRS: &[&str] = &[ "customui/", "xl/externallinks/", "xl/connections.xml", + "xl/xmlmaps.xml", "xl/querytables/", "xl/ctrlprops/", "xl/embeddings/", diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 1aa9b40..0194d22 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -9,6 +9,7 @@ use gridcraft_formula::Expr; use gridcraft_model::{ AutoFilter, Cell, CfOperator, CfRule, CfValueKind, Comment, CondFormat, EditRange, ErrorStyle, FilterCriterion, Formula, Hyperlink, LineInfo, Orientation, PasswordHash, Sheet, SheetProtection, Sparkline, SparklineKind, Table, TableColumn, TotalsFn, Validation, ValidationKind, + XmlColumnPr, }; use crate::IoError; @@ -831,12 +832,24 @@ pub fn read_table(cx: &mut Ctx<'_>, x: &El) -> Option { totals, totals_label, formula: c.child("calculatedColumnFormula").map(|f| from_file(f.text.trim())), + unique_name: c.attr("uniqueName").map(str::to_string), + xml: c.child("xmlColumnPr").and_then(|x| { + Some(XmlColumnPr { + map_id: x.attr_u32("mapId")?, + xpath: x.attr("xpath")?.to_string(), + data_type: x.attr("xmlDataType").unwrap_or("string").to_string(), + denormalized: x.flag("denormalized", false), + }) + }), } }) .collect() }) .unwrap_or_default(); let si = x.child("tableStyleInfo"); + // Only XML tables are kept as such: a query table's definition hangs off the table part, which + // is rewritten, so it would lose it. + let xml_table = x.attr("tableType") == Some("xml"); Some(Table { id: x.attr_u32("id").unwrap_or(0), name, @@ -851,6 +864,8 @@ pub fn read_table(cx: &mut Ctx<'_>, x: &El) -> Option
{ last_col: si.is_some_and(|s| s.flag("showLastColumn", false)), filter_button: x.child("autoFilter").is_some(), filter: x.child("autoFilter").and_then(|e| read_autofilter(cx, e)).map(|af| af.criteria).unwrap_or_default(), + table_type: xml_table.then(|| "xml".to_string()), + connection_id: x.attr_u32("connectionId").filter(|_| xml_table), }) } diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index 4196155..78e1afa 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -1032,6 +1032,12 @@ fn table_xml(t: &Table, cols: &[String], name: &str, id: u32, out: &mut Out) -> } else { s.push_str(" totalsRowShown=\"0\""); } + if let Some(ty) = &t.table_type { + let _ = write!(s, " tableType=\"{}\"", esc_attr(ty)); + } + if let Some(c) = t.connection_id { + let _ = write!(s, " connectionId=\"{c}\""); + } s.push('>'); if t.header_row && t.filter_button { let end_row = if t.totals_row { t.range.end.row.saturating_sub(1).max(t.range.start.row) } else { t.range.end.row }; @@ -1041,7 +1047,11 @@ fn table_xml(t: &Table, cols: &[String], name: &str, id: u32, out: &mut Out) -> let _ = write!(s, "", cols.len()); for (i, name) in cols.iter().enumerate() { let col = t.columns.get(i); - let _ = write!(s, " } let calc = col.and_then(|c| c.formula.as_ref()).filter(|f| !f.is_empty()); let custom = if t.totals_row && totals == TotalsFn::Custom { col.and_then(|c| c.totals_label.as_ref()) } else { None }; - if calc.is_none() && custom.is_none() { + let xml = col.and_then(|c| c.xml.as_ref()); + if calc.is_none() && custom.is_none() && xml.is_none() { s.push_str("/>"); continue; } @@ -1075,6 +1086,13 @@ fn table_xml(t: &Table, cols: &[String], name: &str, id: u32, out: &mut Out) -> if let Some(f) = custom { let _ = write!(s, "{}", esc(&text_to_file(f))); } + if let Some(x) = xml { + let _ = write!(s, "", esc_attr(&x.data_type)); + } s.push_str(""); } s.push_str(""); diff --git a/crates/xlsx/src/tests/array_export.rs b/crates/xlsx/src/tests/array_export.rs index 45764e4..b90b986 100644 --- a/crates/xlsx/src/tests/array_export.rs +++ b/crates/xlsx/src/tests/array_export.rs @@ -174,8 +174,15 @@ fn calculated_table_column_keeps_formula_and_cell_value() { header_row: true, totals_row: false, columns: vec![ - TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None }, - TableColumn { name: "Output".into(), totals: TotalsFn::None, totals_label: None, formula: Some("A2*2".into()) }, + TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }, + TableColumn { + name: "Output".into(), + totals: TotalsFn::None, + totals_label: None, + formula: Some("A2*2".into()), + unique_name: None, + xml: None, + }, ], style: "TableStyleLight1".into(), banded_rows: true, @@ -184,6 +191,8 @@ fn calculated_table_column_keeps_formula_and_cell_value() { last_col: false, filter_button: true, filter: vec![], + table_type: None, + connection_id: None, }); let bytes = write_xlsx(&wb).unwrap(); let mut pkg = Package::open(&bytes).unwrap(); @@ -213,8 +222,15 @@ fn this_row_references_are_written_in_long_form() { header_row: true, totals_row: false, columns: vec![ - TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None }, - TableColumn { name: "Output".into(), totals: TotalsFn::None, totals_label: None, formula: Some("[@Input]*2".into()) }, + TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }, + TableColumn { + name: "Output".into(), + totals: TotalsFn::None, + totals_label: None, + formula: Some("[@Input]*2".into()), + unique_name: None, + xml: None, + }, ], style: "TableStyleLight1".into(), banded_rows: true, @@ -223,6 +239,8 @@ fn this_row_references_are_written_in_long_form() { last_col: false, filter_button: true, filter: vec![], + table_type: None, + connection_id: None, }); let bytes = write_xlsx(&wb).unwrap(); let mut pkg = Package::open(&bytes).unwrap(); @@ -359,8 +377,15 @@ fn implicit_intersection_metadata_distinguishes_table_rows_and_columns() { header_row: true, totals_row: false, columns: vec![ - TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None }, - TableColumn { name: "Output".into(), totals: TotalsFn::None, totals_label: None, formula: Some("[@Input]*2".into()) }, + TableColumn { name: "Input".into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }, + TableColumn { + name: "Output".into(), + totals: TotalsFn::None, + totals_label: None, + formula: Some("[@Input]*2".into()), + unique_name: None, + xml: None, + }, ], style: "TableStyleLight1".into(), banded_rows: true, @@ -369,6 +394,8 @@ fn implicit_intersection_metadata_distinguishes_table_rows_and_columns() { last_col: false, filter_button: true, filter: vec![], + table_type: None, + connection_id: None, }); sh.cells.set(at("A2"), Cell::value(Value::Number(3.0))); sh.cells.set(at("B2"), formula("[@Input]*2", 6.0)); diff --git a/crates/xlsx/src/tests/passthrough_tests.rs b/crates/xlsx/src/tests/passthrough_tests.rs index 51d37ab..d844daa 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -484,6 +484,80 @@ fn kept_vml_drawings_are_charged_to_the_kept_total() { assert!(wb.sheets.iter().all(|s| s.preserved.as_ref().is_some_and(|p| p.vml.is_some()))); } +const XML_MAPS: &str = r#""#; +const SINGLE_CELLS: &str = r#""#; +const XML_CONNECTION: &str = r#""#; + +/// A workbook bound to an XML map, shaped like a Business Central configuration package: the map, +/// an XML table whose columns carry XPaths, a single mapped cell and the XML connection. +fn xml_map_package() -> Vec { + let types = r#""#; + let workbook = format!( + r#""# + ); + let sheet = format!( + r#"27No.Blocked10000"# + ); + let table = format!( + r#"
"# + ); + let root_rels = rels(&[("rId1", "officeDocument", "xl/workbook.xml")]); + let wb_rels = + rels(&[("rId1", "worksheet", "worksheets/sheet1.xml"), ("rId2", "xmlMaps", "xmlMaps.xml"), ("rId3", "connections", "connections.xml")]); + let sheet_rels = rels(&[("rId1", "tableSingleCells", "../tables/tableSingleCells1.xml"), ("rId2", "table", "../tables/table1.xml")]); + make_zip(&[ + ("[Content_Types].xml", types.as_bytes()), + ("_rels/.rels", root_rels.as_bytes()), + ("xl/workbook.xml", workbook.as_bytes()), + ("xl/_rels/workbook.xml.rels", wb_rels.as_bytes()), + ("xl/worksheets/sheet1.xml", sheet.as_bytes()), + ("xl/worksheets/_rels/sheet1.xml.rels", sheet_rels.as_bytes()), + ("xl/tables/table1.xml", table.as_bytes()), + ("xl/tables/tableSingleCells1.xml", SINGLE_CELLS.as_bytes()), + ("xl/connections.xml", XML_CONNECTION.as_bytes()), + ("xl/xmlMaps.xml", XML_MAPS.as_bytes()), + ]) +} + +#[test] +fn xml_maps_and_their_bindings_survive_a_round_trip() { + let (wb, rep) = read_xlsx(&xml_map_package()).unwrap(); + assert!(!rep.warnings.iter().any(|w| w.contains("dropped")), "{:?}", rep.warnings); + let out = write_xlsx(&wb).unwrap(); + let u = Unpacked::new(&out); + u.assert_valid(); + // The map keeps its name (its ids are what the bindings point at) and its relationship. + assert_eq!(u.parts.get("xl/xmlMaps.xml").map(|b| String::from_utf8_lossy(b)).as_deref(), Some(XML_MAPS)); + assert!(u.rels("xl/workbook.xml").iter().any(|r| r.1.ends_with("/xmlMaps") && r.2 == "xl/xmlMaps.xml")); + let types = u.text("[Content_Types].xml"); + assert!( + types.contains(r#"PartName="/xl/xmlMaps.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.xmlMaps+xml""#), + "{types}" + ); + // The single mapped cell hangs off the sheet. + let sheet = sheet_part(&u, "Item"); + assert!(u.rels(&sheet).iter().any(|r| r.1.ends_with("/tableSingleCells") && u.parts.get(&r.2).is_some_and(|b| b == SINGLE_CELLS.as_bytes()))); + // The table stays an XML table, its columns keep their XPaths. + let (_, table) = u.rels(&sheet).into_iter().map(|r| (r.1, r.2)).find(|(ty, _)| ty.ends_with("/table")).unwrap(); + let t = xml::parse(u.text(&table).as_bytes()).unwrap(); + assert_eq!((t.attr("tableType"), t.attr("connectionId")), (Some("xml"), Some("1"))); + let cols: Vec<_> = t.child("tableColumns").unwrap().kids("tableColumn").collect(); + let mapped: Vec<_> = cols + .iter() + .map(|c| { + let x = c.child("xmlColumnPr").unwrap(); + (c.attr("uniqueName"), x.attr("mapId"), x.attr("xpath"), x.attr("xmlDataType")) + }) + .collect(); + assert_eq!( + mapped, + [ + (Some("No"), Some("1"), Some("/DataList/ItemList/Item/No"), Some("string")), + (Some("Blocked"), Some("1"), Some("/DataList/ItemList/Item/Blocked"), Some("boolean")), + ] + ); +} + /// Where a link with `target`, in a workbook read from `from`, points once saved into `to`. #[cfg(unix)] fn rebased(target: &str, from: &str, to: &str) -> String { diff --git a/crates/xlsx/src/tests/pivot_tests.rs b/crates/xlsx/src/tests/pivot_tests.rs index 0c845aa..99a15d6 100644 --- a/crates/xlsx/src/tests/pivot_tests.rs +++ b/crates/xlsx/src/tests/pivot_tests.rs @@ -63,7 +63,10 @@ fn workbook() -> Workbook { range: rr("A1:F9"), header_row: true, totals_row: false, - columns: headers.iter().map(|h| TableColumn { name: (*h).into(), totals: TotalsFn::None, totals_label: None, formula: None }).collect(), + columns: headers + .iter() + .map(|h| TableColumn { name: (*h).into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }) + .collect(), style: "TableStyleMedium2".into(), banded_rows: true, banded_cols: false, @@ -71,6 +74,8 @@ fn workbook() -> Workbook { last_col: false, filter_button: true, filter: vec![], + table_type: None, + connection_id: None, }); wb.names.push(DefinedName { name: "SalesData".into(), scope: None, formula: "Data!$A$1:$F$9".into(), comment: String::new(), hidden: false }); diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index e9d72d5..9018029 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -290,9 +290,23 @@ fn sample() -> Workbook { header_row: true, totals_row: true, columns: vec![ - TableColumn { name: "Region".into(), totals: TotalsFn::None, totals_label: Some("Total".into()), formula: None }, - TableColumn { name: "Amount".into(), totals: TotalsFn::Sum, totals_label: None, formula: None }, - TableColumn { name: "Tax".into(), totals: TotalsFn::Custom, totals_label: Some("SUM(R2:R4)".into()), formula: Some("Q2*0.1".into()) }, + TableColumn { + name: "Region".into(), + totals: TotalsFn::None, + totals_label: Some("Total".into()), + formula: None, + unique_name: None, + xml: None, + }, + TableColumn { name: "Amount".into(), totals: TotalsFn::Sum, totals_label: None, formula: None, unique_name: None, xml: None }, + TableColumn { + name: "Tax".into(), + totals: TotalsFn::Custom, + totals_label: Some("SUM(R2:R4)".into()), + formula: Some("Q2*0.1".into()), + unique_name: None, + xml: None, + }, ], style: "TableStyleLight9".into(), banded_rows: true, @@ -304,6 +318,8 @@ fn sample() -> Workbook { (0, FilterCriterion::Values { values: vec!["East".into()], blanks: false }), (1, FilterCriterion::Icon { set: "3TrafficLights1".into(), id: Some(1) }), ], + table_type: None, + connection_id: None, }); let png = b"\x89PNG\r\n\x1a\n0000IHDRfake".to_vec(); s.images.push(Image { @@ -691,8 +707,8 @@ fn writing_repairs_invalid_models() { header_row: true, totals_row: false, columns: vec![ - TableColumn { name: "X".into(), totals: TotalsFn::None, totals_label: None, formula: None }, - TableColumn { name: "x".into(), totals: TotalsFn::None, totals_label: None, formula: None }, + TableColumn { name: "X".into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }, + TableColumn { name: "x".into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }, ], style: String::new(), banded_rows: true, @@ -701,6 +717,8 @@ fn writing_repairs_invalid_models() { last_col: false, filter_button: true, filter: vec![], + table_type: None, + connection_id: None, }); s.cells.set(at("B2"), Cell { value: Value::Number(1.0), formula: None, style: StyleId(9999) }); let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); @@ -962,7 +980,7 @@ fn table_newline_column_and_backslash_name_survive() { s.set_value(at("A1"), Value::text("Handle\nBars")); s.set_value(at("B1"), Value::text("Name")); s.set_value(at("B2"), Value::text("x")); - let col = |n: &str| TableColumn { name: n.into(), totals: TotalsFn::None, totals_label: None, formula: None }; + let col = |n: &str| TableColumn { name: n.into(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None }; s.tables.push(Table { id: 1, name: "\\_Prime.1".into(), @@ -977,6 +995,8 @@ fn table_newline_column_and_backslash_name_survive() { last_col: false, filter_button: true, filter: vec![], + table_type: None, + connection_id: None, }); s.cells.set(at("D1"), Cell::formula(Formula::new("COUNTA(\\_Prime.1[Name])"))); let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); @@ -1001,8 +1021,15 @@ fn header_and_totals_table_without_data_row_survives() { header_row: true, totals_row: true, columns: vec![ - TableColumn { name: "Name".into(), totals: TotalsFn::None, totals_label: Some("Total".into()), formula: None }, - TableColumn { name: "Amount".into(), totals: TotalsFn::Sum, totals_label: None, formula: None }, + TableColumn { + name: "Name".into(), + totals: TotalsFn::None, + totals_label: Some("Total".into()), + formula: None, + unique_name: None, + xml: None, + }, + TableColumn { name: "Amount".into(), totals: TotalsFn::Sum, totals_label: None, formula: None, unique_name: None, xml: None }, ], style: "TableStyleMedium2".into(), banded_rows: true, @@ -1011,6 +1038,8 @@ fn header_and_totals_table_without_data_row_survives() { last_col: true, filter_button: true, filter: vec![], + table_type: None, + connection_id: None, }); let (back, report) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap(); assert!(report.warnings.is_empty(), "{:?}", report.warnings);