Skip to main content

sheetkit_xml/
slicer.rs

1//! Slicer XML schema structures.
2//!
3//! Represents `xl/slicers/slicer{N}.xml` and `xl/slicerCaches/slicerCache{N}.xml`
4//! in the OOXML package. These are Office 2010+ extensions for visual filter controls.
5//!
6//! The slicer cache XML uses namespace-prefixed elements (`x15:tableSlicerCache`)
7//! which cannot round-trip through serde. The `SlicerCacheDefinition` is therefore
8//! serialized manually during workbook save.
9
10use serde::{Deserialize, Serialize};
11
12/// Root element for a slicer definition part (`xl/slicers/slicer{N}.xml`).
13///
14/// Contains one or more slicer definitions that describe visual filter controls
15/// linked to table or pivot table columns.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[serde(rename = "slicers")]
18pub struct SlicerDefinitions {
19    #[serde(rename = "@xmlns")]
20    pub xmlns: String,
21
22    #[serde(rename = "@xmlns:mc", skip_serializing_if = "Option::is_none")]
23    pub xmlns_mc: Option<String>,
24
25    #[serde(rename = "slicer", default)]
26    pub slicers: Vec<SlicerDefinition>,
27}
28
29/// A single slicer definition within the slicers part.
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
31pub struct SlicerDefinition {
32    #[serde(rename = "@name")]
33    pub name: String,
34
35    #[serde(rename = "@cache")]
36    pub cache: String,
37
38    #[serde(rename = "@caption", skip_serializing_if = "Option::is_none")]
39    pub caption: Option<String>,
40
41    #[serde(rename = "@startItem", skip_serializing_if = "Option::is_none")]
42    pub start_item: Option<u32>,
43
44    #[serde(rename = "@columnCount", skip_serializing_if = "Option::is_none")]
45    pub column_count: Option<u32>,
46
47    #[serde(rename = "@showCaption", skip_serializing_if = "Option::is_none")]
48    pub show_caption: Option<bool>,
49
50    #[serde(rename = "@style", skip_serializing_if = "Option::is_none")]
51    pub style: Option<String>,
52
53    #[serde(rename = "@lockedPosition", skip_serializing_if = "Option::is_none")]
54    pub locked_position: Option<bool>,
55
56    #[serde(rename = "@rowHeight")]
57    pub row_height: u32,
58}
59
60/// In-memory representation of a slicer cache definition.
61///
62/// Serialized manually (not via serde) because the XML uses namespace-prefixed
63/// child elements (`x15:tableSlicerCache`) that serde cannot handle.
64#[derive(Debug, Clone, PartialEq)]
65pub struct SlicerCacheDefinition {
66    pub name: String,
67    pub source_name: String,
68    pub table_slicer_cache: Option<TableSlicerCache>,
69}
70
71/// Tabular slicer cache linking the slicer to a table column.
72#[derive(Debug, Clone, PartialEq)]
73pub struct TableSlicerCache {
74    pub table_id: u32,
75    pub column: u32,
76}
77
78/// Serialize a `SlicerCacheDefinition` to XML manually.
79///
80/// Produces the full `slicerCacheDefinition` element with proper namespace
81/// prefixes for the table slicer cache extension.
82pub fn serialize_slicer_cache(def: &SlicerCacheDefinition) -> String {
83    use std::fmt::Write;
84
85    let ns_x14 = crate::namespaces::SLICER_2009;
86    let ns_x15 = crate::namespaces::SLICER_2010;
87    let ns_mc = crate::namespaces::MC;
88
89    let mut xml = String::new();
90    let _ = write!(
91        xml,
92        "<slicerCacheDefinition \
93         xmlns=\"{ns_x14}\" \
94         xmlns:mc=\"{ns_mc}\" \
95         name=\"{}\" \
96         sourceName=\"{}\"",
97        escape_xml_attr(&def.name),
98        escape_xml_attr(&def.source_name),
99    );
100
101    if let Some(ref tsc) = def.table_slicer_cache {
102        let _ = write!(xml, ">");
103        let _ = write!(
104            xml,
105            "<extLst>\
106             <ext xmlns:x15=\"{ns_x15}\" \
107             uri=\"{{2F2917AC-EB37-4324-AD4E-5DD8C200BD13}}\">\
108             <x15:tableSlicerCache tableId=\"{}\" column=\"{}\"/>\
109             </ext>\
110             </extLst>",
111            tsc.table_id, tsc.column,
112        );
113        let _ = write!(xml, "</slicerCacheDefinition>");
114    } else {
115        let _ = write!(xml, "/>");
116    }
117
118    xml
119}
120
121/// Parse a `SlicerCacheDefinition` from XML.
122///
123/// Handles the namespace-prefixed `x15:tableSlicerCache` element that serde
124/// cannot deserialize. Uses simple string matching for robustness.
125pub fn parse_slicer_cache(xml: &str) -> Option<SlicerCacheDefinition> {
126    let name = extract_attr(xml, "name")?;
127    let source_name = extract_attr(xml, "sourceName")?;
128
129    let table_slicer_cache = if let Some(tsc_start) = xml.find("tableSlicerCache") {
130        let remainder = &xml[tsc_start..];
131        let table_id = extract_attr(remainder, "tableId").and_then(|s| s.parse::<u32>().ok());
132        let column = extract_attr(remainder, "column").and_then(|s| s.parse::<u32>().ok());
133        match (table_id, column) {
134            (Some(tid), Some(col)) => Some(TableSlicerCache {
135                table_id: tid,
136                column: col,
137            }),
138            _ => None,
139        }
140    } else {
141        None
142    };
143
144    Some(SlicerCacheDefinition {
145        name,
146        source_name,
147        table_slicer_cache,
148    })
149}
150
151/// Extract a named XML attribute value from an element string.
152fn extract_attr(xml: &str, attr_name: &str) -> Option<String> {
153    let pattern = format!("{}=\"", attr_name);
154    let start = xml.find(&pattern)?;
155    let after_eq = start + pattern.len();
156    let end = xml[after_eq..].find('"')?;
157    Some(xml[after_eq..after_eq + end].to_string())
158}
159
160/// Escape special characters for use in XML attribute values.
161fn escape_xml_attr(s: &str) -> String {
162    s.replace('&', "&amp;")
163        .replace('<', "&lt;")
164        .replace('>', "&gt;")
165        .replace('"', "&quot;")
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_slicer_definition_roundtrip() {
174        let defs = SlicerDefinitions {
175            xmlns: "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main".to_string(),
176            xmlns_mc: None,
177            slicers: vec![SlicerDefinition {
178                name: "Slicer_Category".to_string(),
179                cache: "Slicer_Category".to_string(),
180                caption: Some("Category".to_string()),
181                start_item: None,
182                column_count: None,
183                show_caption: Some(true),
184                style: Some("SlicerStyleLight1".to_string()),
185                locked_position: None,
186                row_height: 241300,
187            }],
188        };
189
190        let xml = quick_xml::se::to_string(&defs).unwrap();
191        assert!(xml.contains("Slicer_Category"));
192        assert!(xml.contains("SlicerStyleLight1"));
193
194        let parsed: SlicerDefinitions = quick_xml::de::from_str(&xml).unwrap();
195        assert_eq!(parsed.slicers.len(), 1);
196        assert_eq!(parsed.slicers[0].name, "Slicer_Category");
197        assert_eq!(parsed.slicers[0].row_height, 241300);
198    }
199
200    #[test]
201    fn test_slicer_cache_serialize_and_parse() {
202        let cache = SlicerCacheDefinition {
203            name: "Slicer_Category".to_string(),
204            source_name: "Category".to_string(),
205            table_slicer_cache: Some(TableSlicerCache {
206                table_id: 1,
207                column: 2,
208            }),
209        };
210
211        let xml = serialize_slicer_cache(&cache);
212        assert!(xml.contains("Slicer_Category"));
213        assert!(xml.contains("sourceName=\"Category\""));
214        assert!(xml.contains("x15:tableSlicerCache"));
215        assert!(xml.contains("tableId=\"1\""));
216        assert!(xml.contains("column=\"2\""));
217
218        let parsed = parse_slicer_cache(&xml).unwrap();
219        assert_eq!(parsed.name, "Slicer_Category");
220        assert_eq!(parsed.source_name, "Category");
221        let tsc = parsed.table_slicer_cache.unwrap();
222        assert_eq!(tsc.table_id, 1);
223        assert_eq!(tsc.column, 2);
224    }
225
226    #[test]
227    fn test_slicer_definition_minimal() {
228        let def = SlicerDefinition {
229            name: "S1".to_string(),
230            cache: "S1".to_string(),
231            caption: None,
232            start_item: None,
233            column_count: None,
234            show_caption: None,
235            style: None,
236            locked_position: None,
237            row_height: 241300,
238        };
239
240        let xml = quick_xml::se::to_string(&def).unwrap();
241        assert!(!xml.contains("caption="));
242        assert!(!xml.contains("style="));
243        assert!(xml.contains("rowHeight=\"241300\""));
244    }
245
246    #[test]
247    fn test_multiple_slicers_in_definitions() {
248        let defs = SlicerDefinitions {
249            xmlns: "http://schemas.microsoft.com/office/spreadsheetml/2009/9/main".to_string(),
250            xmlns_mc: None,
251            slicers: vec![
252                SlicerDefinition {
253                    name: "Slicer_A".to_string(),
254                    cache: "Slicer_A".to_string(),
255                    caption: Some("Column A".to_string()),
256                    start_item: None,
257                    column_count: Some(2),
258                    show_caption: Some(true),
259                    style: Some("SlicerStyleDark1".to_string()),
260                    locked_position: None,
261                    row_height: 241300,
262                },
263                SlicerDefinition {
264                    name: "Slicer_B".to_string(),
265                    cache: "Slicer_B".to_string(),
266                    caption: Some("Column B".to_string()),
267                    start_item: None,
268                    column_count: None,
269                    show_caption: Some(false),
270                    style: None,
271                    locked_position: Some(true),
272                    row_height: 241300,
273                },
274            ],
275        };
276
277        let xml = quick_xml::se::to_string(&defs).unwrap();
278        let parsed: SlicerDefinitions = quick_xml::de::from_str(&xml).unwrap();
279        assert_eq!(parsed.slicers.len(), 2);
280        assert_eq!(parsed.slicers[0].name, "Slicer_A");
281        assert_eq!(parsed.slicers[1].name, "Slicer_B");
282    }
283
284    #[test]
285    fn test_slicer_cache_without_table_cache() {
286        let cache = SlicerCacheDefinition {
287            name: "Slicer_X".to_string(),
288            source_name: "ColumnX".to_string(),
289            table_slicer_cache: None,
290        };
291
292        let xml = serialize_slicer_cache(&cache);
293        assert!(!xml.contains("tableSlicerCache"));
294        assert!(xml.contains("Slicer_X"));
295
296        let parsed = parse_slicer_cache(&xml).unwrap();
297        assert_eq!(parsed.name, "Slicer_X");
298        assert!(parsed.table_slicer_cache.is_none());
299    }
300
301    #[test]
302    fn test_slicer_cache_escapes_special_chars() {
303        let cache = SlicerCacheDefinition {
304            name: "Slicer_A&B".to_string(),
305            source_name: "Col<1>".to_string(),
306            table_slicer_cache: None,
307        };
308
309        let xml = serialize_slicer_cache(&cache);
310        assert!(xml.contains("Slicer_A&amp;B"));
311        assert!(xml.contains("Col&lt;1&gt;"));
312    }
313
314    #[test]
315    fn test_extract_attr() {
316        let xml = r#"<elem name="hello" id="42">"#;
317        assert_eq!(extract_attr(xml, "name"), Some("hello".to_string()));
318        assert_eq!(extract_attr(xml, "id"), Some("42".to_string()));
319        assert_eq!(extract_attr(xml, "missing"), None);
320    }
321}