XLSX: XML maps survive a save, with their table columns and mapped cells (part of #860)
This commit is contained in:
parent
fc45b7f6d8
commit
2dd832e2d5
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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()}))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>");
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue