XLSX: XML maps survive a save, with their table columns and mapped cells (part of #860)

This commit is contained in:
Marco Arnulfo 2026-10-13 02:45:09 +02:00
parent fc45b7f6d8
commit 2dd832e2d5
13 changed files with 235 additions and 26 deletions

View File

@ -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.

View File

@ -821,7 +821,14 @@ fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
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;

View File

@ -351,7 +351,10 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
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<Json> {
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));

View File

@ -310,7 +310,10 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
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<Json> {
last_col: false,
filter_button: true,
filter: vec![],
table_type: None,
connection_id: None,
});
cx.structural = true;
Ok(json!({"table": name, "range": r.a1()}))

View File

@ -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);
}
}

View File

@ -48,6 +48,23 @@ pub struct TableColumn {
pub totals_label: Option<String>,
/// Calculated column formula.
pub formula: Option<String>,
/// `uniqueName`: the name XML maps know the column by.
#[serde(default)]
pub unique_name: Option<String>,
/// 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<XmlColumnPr>,
}
/// A table column's binding to an XML map (`<xmlColumnPr>`).
#[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<String>,
/// The connection (`xl/connections.xml`) an XML table is bound through.
#[serde(default)]
pub connection_id: Option<u32>,
}
impl Table {

View File

@ -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 `<externalReferences>`, data connections) and each sheet (query tables, form controls,
//! ActiveX controls, OLE objects, the sheet background and printer settings, with the sheet's
//! `<picture>`, `<oleObjects>` and `<controls>` XML, the `r:id` of `<pageSetup>` and the legacy
//! VML shapes those controls need).
//! their `<externalReferences>`, 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 `<picture>`, `<oleObjects>` and `<controls>` XML, the `r:id`
//! of `<pageSetup>` 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 `<legacyDrawing>`, before `<tableParts>`).
@ -139,6 +139,7 @@ const OWN_DIRS: &[&str] = &[
"customui/",
"xl/externallinks/",
"xl/connections.xml",
"xl/xmlmaps.xml",
"xl/querytables/",
"xl/ctrlprops/",
"xl/embeddings/",

View File

@ -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<Table> {
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<Table> {
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),
})
}

View File

@ -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, "<tableColumns count=\"{}\">", cols.len());
for (i, name) in cols.iter().enumerate() {
let col = t.columns.get(i);
let _ = write!(s, "<tableColumn id=\"{}\" name=\"{}\"", i + 1, esc_attr(&encode_xstring(name)));
let _ = write!(s, "<tableColumn id=\"{}\"", i + 1);
if let Some(u) = col.and_then(|c| c.unique_name.as_ref()) {
let _ = write!(s, " uniqueName=\"{}\"", esc_attr(u));
}
let _ = write!(s, " name=\"{}\"", esc_attr(&encode_xstring(name)));
let totals = col.map(|c| c.totals).unwrap_or_default();
if t.totals_row {
let f = match totals {
@ -1006,7 +1016,8 @@ fn table_xml(t: &Table, cols: &[String], name: &str, id: u32, out: &mut Out) ->
}
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, "<totalsRowFormula>{}</totalsRowFormula>", esc(&text_to_file(f)));
}
if let Some(x) = xml {
let _ = write!(s, "<xmlColumnPr mapId=\"{}\" xpath=\"{}\"", x.map_id, esc_attr(&x.xpath));
if x.denormalized {
s.push_str(" denormalized=\"1\"");
}
let _ = write!(s, " xmlDataType=\"{}\"/>", esc_attr(&x.data_type));
}
s.push_str("</tableColumn>");
}
s.push_str("</tableColumns>");

View File

@ -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));

View File

@ -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#"<?xml version="1.0"?><MapInfo xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" SelectionNamespaces=""><Schema ID="Schema1"><xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema"><xsd:element name="DataList"><xsd:complexType><xsd:sequence><xsd:element name="ItemList"><xsd:complexType><xsd:sequence><xsd:element name="TableID" type="xsd:integer"/><xsd:element name="Item" maxOccurs="unbounded"><xsd:complexType><xsd:sequence><xsd:element name="No" type="xsd:string"/><xsd:element name="Blocked" type="xsd:boolean"/></xsd:sequence></xsd:complexType></xsd:element></xsd:sequence></xsd:complexType></xsd:element></xsd:sequence></xsd:complexType></xsd:element></xsd:schema></Schema><Map ID="1" Name="DataList_Map" RootElement="DataList" SchemaID="Schema1" ShowImportExportValidationErrors="0" AutoFit="1" Append="0" PreserveSortAFLayout="1" PreserveFormat="1"><DataBinding FileBinding="1" ConnectionID="1" DataBindingLoadMode="1"/></Map></MapInfo>"#;
const SINGLE_CELLS: &str = r#"<?xml version="1.0"?><singleXmlCells xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><singleXmlCell id="3" r="C1" connectionId="1"><xmlCellPr id="1" uniqueName="TableID"><xmlPr mapId="1" xpath="/DataList/ItemList/TableID" xmlDataType="integer"/></xmlCellPr></singleXmlCell></singleXmlCells>"#;
const XML_CONNECTION: &str = r#"<?xml version="1.0"?><connections xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><connection id="1" name="DataList" type="4" refreshedVersion="0" background="1"><webPr xml="1" sourceData="1" url="C:\temp\datalist.xml" htmlTables="1"/></connection></connections>"#;
/// 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<u8> {
let types = r#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/xmlMaps.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.xmlMaps+xml"/><Override PartName="/xl/connections.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml"/><Override PartName="/xl/tables/table1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml"/><Override PartName="/xl/tables/tableSingleCells1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.tableSingleCells+xml"/></Types>"#;
let workbook = format!(
r#"<?xml version="1.0"?><workbook xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheets><sheet name="Item" sheetId="1" r:id="rId1"/></sheets></workbook>"#
);
let sheet = format!(
r#"<?xml version="1.0"?><worksheet xmlns="{NS_MAIN}" xmlns:r="{NS_R}"><sheetData><row r="1"><c r="C1"><v>27</v></c></row><row r="3"><c r="A3" t="inlineStr"><is><t>No.</t></is></c><c r="B3" t="inlineStr"><is><t>Blocked</t></is></c></row><row r="4"><c r="A4" t="inlineStr"><is><t>1000</t></is></c><c r="B4" t="b"><v>0</v></c></row></sheetData><tableParts count="1"><tablePart r:id="rId2"/></tableParts></worksheet>"#
);
let table = format!(
r#"<?xml version="1.0"?><table xmlns="{NS_MAIN}" id="1" name="Table1" displayName="Table1" ref="A3:B4" tableType="xml" connectionId="1" totalsRowShown="0"><autoFilter ref="A3:B4"/><tableColumns count="2"><tableColumn id="1" uniqueName="No" name="No."><xmlColumnPr mapId="1" xpath="/DataList/ItemList/Item/No" xmlDataType="string"/></tableColumn><tableColumn id="2" uniqueName="Blocked" name="Blocked"><xmlColumnPr mapId="1" xpath="/DataList/ItemList/Item/Blocked" xmlDataType="boolean"/></tableColumn></tableColumns><tableStyleInfo name="TableStyleMedium2" showRowStripes="1"/></table>"#
);
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")),
]
);
}

View File

@ -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 });

View File

@ -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();