diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index 052d620..3865eda 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -336,10 +336,46 @@ fn num_data(tag: &str, f: Option<&str>, wb: &Workbook, si: usize) -> String { s } +/// Splits the inside of an array constant on `,` and `;` outside double quotes; inside quotes `""` is a literal quote. +/// Unquoted items are trimmed; quoted ones keep the text between the quotes and drop the spaces around them. +fn split_array_items(inner: &str) -> Vec { + let mut items = Vec::new(); + let mut cur = String::new(); + let mut quoted = false; + let mut in_quote = false; + let mut chars = inner.chars().peekable(); + let finish = |cur: &mut String, quoted: &mut bool| { + let text = std::mem::take(cur); + let item = if *quoted { text } else { text.trim().to_string() }; + *quoted = false; + item + }; + while let Some(c) = chars.next() { + match c { + '"' if in_quote && chars.peek() == Some(&'"') => { + cur.push('"'); + chars.next(); + } + '"' => { + if !quoted && cur.trim().is_empty() { + cur.clear(); + } + in_quote = !in_quote; + quoted = true; + } + ',' | ';' if !in_quote => items.push(finish(&mut cur, &mut quoted)), + c if quoted && !in_quote && c.is_whitespace() => {} + _ => cur.push(c), + } + } + items.push(finish(&mut cur, &mut quoted)); + items +} + fn str_data(tag: &str, f: &str, wb: &Workbook, si: usize) -> String { let f = f.trim(); if f.starts_with('{') { - let vals: Vec = f.trim_matches(|c| c == '{' || c == '}').split([',', ';']).map(|v| v.trim().trim_matches('"').to_string()).collect(); + let vals = split_array_items(f.trim_matches(|c| c == '{' || c == '}')); let mut s = format!("", vals.len()); for (i, v) in vals.iter().enumerate() { let _ = write!(s, "{}", esc(v)); diff --git a/crates/xlsx/src/tests/literal_category_roundtrip.rs b/crates/xlsx/src/tests/literal_category_roundtrip.rs new file mode 100644 index 0000000..c818de2 --- /dev/null +++ b/crates/xlsx/src/tests/literal_category_roundtrip.rs @@ -0,0 +1,59 @@ +//! Literal chart categories that contain a comma, a semicolon or a quote stay one category on save. + +use gridcraft_model::{Anchor, Chart, ChartKind, LegendPos, Series, Sheet, Workbook}; +use std::sync::Arc; + +use crate::{read_xlsx, write_xlsx}; + +#[test] +fn literal_category_with_separators_survives_save() { + // (typed array constant, what reading the saved file gives back) + let cats = [ + (r#"{"a,b"}"#, r#"{"a,b"}"#), + (r#"{"a;b"}"#, r#"{"a;b"}"#), + (r#"{"red","blue"}"#, r#"{"red","blue"}"#), + (r#"{"say ""hi"", ok"}"#, r#"{"say ""hi"", ok"}"#), + (r#"{"a,b","c;d","e"}"#, r#"{"a,b","c;d","e"}"#), + (r#"{ "Jan" , "Feb, Mar" ; Apr }"#, r#"{"Jan","Feb, Mar","Apr"}"#), + ]; + let mut wb = Workbook::default(); + let mut s = Sheet::new("Data"); + let series: Vec = cats + .iter() + .map(|(c, _)| Series { + name: Some("North, South".into()), + categories: Some((*c).into()), + values: "{1,2,3}".into(), + bubble_sizes: None, + color: None, + secondary: false, + kind: None, + smooth: false, + trendlines: Vec::new(), + }) + .collect(); + s.charts.push(Chart { + id: 1, + kind: ChartKind::ColumnClustered, + anchor: Anchor { cell: gridcraft_core::CellRef::new(0, 3), width: 480.0, height: 300.0, ..Anchor::default() }, + title: None, + series, + legend: LegendPos::Bottom, + data_labels: false, + gridlines: true, + style: 2, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }); + wb.sheets = vec![Arc::new(s)]; + let bytes = write_xlsx(&wb).unwrap(); + let (back, _rep) = read_xlsx(&bytes).unwrap(); + let saved = &back.sheets[0].charts[0].series; + for (i, (_, want)) in cats.iter().enumerate() { + assert_eq!(saved[i].categories.as_deref(), Some(*want)); + assert_eq!(saved[i].name.as_deref(), Some("North, South")); + } +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index 05302ad..dd06442 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -4,6 +4,7 @@ mod array_export; mod csv_tests; mod encrypted_tests; mod fixtures; +mod literal_category_roundtrip; mod malformed; mod ods_tests; mod passthrough_tests;