diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 5133409..0e603df 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -346,6 +346,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)"); @@ -581,7 +583,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, @@ -589,6 +598,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 a08f080..f5f89b9 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -821,7 +821,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 13c120c..efd2289 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -351,7 +351,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, @@ -359,6 +362,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 392c5ee..ff319ce 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -310,7 +310,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, @@ -318,6 +321,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 f05b77d..fa73148 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -982,7 +982,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 e04db25..4c591a1 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/xlsx/src/passthrough.rs b/crates/xlsx/src/passthrough.rs index f121977..510b20a 100644 --- a/crates/xlsx/src/passthrough.rs +++ b/crates/xlsx/src/passthrough.rs @@ -3,10 +3,10 @@ //! 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). +//! 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). //! //! 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: @@ -16,8 +16,8 @@ //! - External relationship targets (the path of a linked workbook) are kept as text and never //! opened, followed or refreshed. //! - 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. @@ -78,9 +78,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"]; /// Kept sheet elements, in schema order (all after ``, before ``). @@ -139,6 +139,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 77b46d9..08b3f32 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; @@ -821,12 +822,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, @@ -841,6 +854,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 0bfcb7d..3c1394e 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -974,6 +974,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 }; @@ -983,7 +989,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; } @@ -1017,6 +1028,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 0491efd..83a0877 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(); @@ -322,8 +331,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, @@ -332,6 +348,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 4cde9d6..bb83092 100644 --- a/crates/xlsx/src/tests/passthrough_tests.rs +++ b/crates/xlsx/src/tests/passthrough_tests.rs @@ -483,3 +483,77 @@ fn kept_vml_drawings_are_charged_to_the_kept_total() { let (wb, _) = read_xlsx(&rich_package_with(CONTENT_TYPES, true)).unwrap(); 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")), + ] + ); +} diff --git a/crates/xlsx/src/tests/pivot_tests.rs b/crates/xlsx/src/tests/pivot_tests.rs index c854432..3c0ff4c 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 9601572..0023d0d 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();