From 42ca769d136d301a88a2ac2d350f49524a972627 Mon Sep 17 00:00:00 2001 From: Ben Pfaff Date: Mon, 27 Apr 2026 09:24:58 -0700 Subject: [PATCH] Fix converting exported graphs back into SPV. --- rust/pspp/src/spv/read/graph.rs | 114 ++++++++++++++++++++------- rust/pspp/src/spv/read/legacy_bin.rs | 35 +++++++- rust/pspp/src/spv/read/structure.rs | 4 +- rust/pspp/src/spv/write.rs | 2 + 4 files changed, 124 insertions(+), 31 deletions(-) diff --git a/rust/pspp/src/spv/read/graph.rs b/rust/pspp/src/spv/read/graph.rs index dd633ceef7..bdd1f5778c 100644 --- a/rust/pspp/src/spv/read/graph.rs +++ b/rust/pspp/src/spv/read/graph.rs @@ -36,7 +36,7 @@ use num::{Integer, NumCast}; use ordered_float::OrderedFloat; use pango::FontDescription; use quick_xml::{ - Reader, Writer, + DeError, Reader, Writer, events::{BytesStart, Event}, }; use rs_stats::distributions::normal_distribution::normal_pdf; @@ -144,9 +144,6 @@ pub enum GraphError { /// Error parsing XML data: {0} XmlError(#[from] serde_path_to_error::Error), - /// CSV data has unknown encoding {0} - UnknownCsvEncoding(String), - /// Graph has no `coordinates` element. NoCoordinates, @@ -1240,6 +1237,10 @@ where } impl EmbeddedSource { + fn from_xml(xml: &[u8]) -> Result { + quick_xml::de::from_reader(Cursor::new(xml)) + } + fn decode(&self) -> DataSource { let mut series = (0..self.names.len()) .map(|i| DataSeries { @@ -4812,29 +4813,9 @@ impl crate::output::Graph { } pub fn csv_data(&self) -> Result, GraphError> { - if let Some(csv) = &self.csv { - let mut reader = csv::Reader::from_reader(Cursor::new(&csv)); - let headers = reader.headers().unwrap(); // XXX - let mut variables = vec![DataSeries::default(); headers.len()]; - for row in reader.records() { - let row = row.unwrap(); // XXX - for (column, s) in row.iter().enumerate() { - let value = match s.parse::() { - Ok(number) => Datum::Number(Some(number)), - Err(_) => Datum::String(s.into()), - }; - variables[column].data.push(value); - } - } - Ok(Some(DataSource( - zip( - reader.headers().unwrap().iter().map(|name| name.into()), - variables, - ) - .collect(), - ))) - } else { - Ok(None) + match &self.csv { + Some(csv) => Ok(Some(DataSource::from_csv_data(&csv).unwrap() /*XXX*/)), + _ => Ok(None), } } @@ -4852,8 +4833,85 @@ impl crate::output::Graph { visualization.decode(self.binary_data(&mut warn)?, self.csv_data()?, &mut warn) } - //pub fn to_spv(self) -> Self {} + /// Returns a version of this graph that is most suitable for including in + /// an SPV file. This reverses the transformation made by + /// [to_chart_xml](Self::to_chart_xml): while the output produced by that + /// function is valid and SPSS will display the graphs, an apparent bug in + /// some SPSS versions keeps it from editing them. + /// + /// Most graphs don't need changes; for those, this function returns the + /// original graph. + pub fn to_spv(&self) -> Cow<'_, crate::output::Graph> { + if self.data.is_some() { + return Cow::Borrowed(self); + } + + let mut data = IndexMap::new(); + let mut reader = Reader::from_reader(self.xml.as_slice()); + let mut writer = Writer::new(Vec::new()); + loop { + let start_position = reader.buffer_position() as usize; + let event = match reader.read_event() { + Ok(event) => event, + Err(_) => todo!(), + }; + + fn id_attribute<'a>(start: &'a BytesStart) -> Option> { + for attribute in start.attributes() { + if let Ok(attribute) = attribute + && attribute.key.local_name().as_ref() == b"id" + && let Ok(value) = attribute.unescape_value() + { + return Some(value); + } + } + None + } + + match &event { + Event::Start(start) => { + let local_name = start.local_name(); + if local_name.as_ref() == b"embeddedSource" + && let Some(id) = id_attribute(start) + { + reader.read_to_end(start.to_end().name()).expect("xxx"); + let end_position = reader.buffer_position() as usize; + let data_source = + EmbeddedSource::from_xml(&self.xml[start_position..end_position]) + .expect("xxx") + .decode(); + writer + .create_element("userSource") + .with_attribute(("id", id.as_ref())) + .write_empty() + .expect("writing to a vector shouldn't fail"); + data.insert(id.into_owned(), data_source); + } else { + writer + .write_event(event) + .expect("writing to a vector shouldn't fail"); + } + } + Event::Eof => break, + _ => writer + .write_event(event) + .expect("writing to a vector shouldn't fail"), + } + } + + if data.is_empty() { + Cow::Borrowed(self) + } else { + Cow::Owned(crate::output::Graph { + xml: writer.into_inner(), + data: Some(DataSources(data).to_legacy_bin()), + csv: self.csv.clone(), + }) + } + } + /// Returns a version of this graph that can be written as a separate XML + /// file outside any SPV file, by embedding the data in the XML. pub fn to_chart_xml(&self, mut warn: F) -> Result, GraphError> where F: FnMut(GraphWarning), diff --git a/rust/pspp/src/spv/read/legacy_bin.rs b/rust/pspp/src/spv/read/legacy_bin.rs index 6b1313d674..a7d36d2a3a 100644 --- a/rust/pspp/src/spv/read/legacy_bin.rs +++ b/rust/pspp/src/spv/read/legacy_bin.rs @@ -3,6 +3,7 @@ use core::f64; use std::{ fmt::Display, io::{Cursor, Read, Seek, SeekFrom, Write}, + iter::zip, }; use binrw::{BinRead, BinResult, BinWrite, Endian, binread, binwrite}; @@ -101,6 +102,7 @@ impl DataSources { .count() as u32; let mut labels = IndexMap::<&str, u32>::new(); if n_maps > 0 { + let mut count = 0; n_maps.write_le(&mut bin).unwrap(); for ((source_name, source), variable_strings) in self.0.iter().zip(&n_strings) { let Some((_n, last_string)) = variable_strings @@ -111,10 +113,16 @@ impl DataSources { else { continue; }; + count += 1; (SpvString(source_name), last_string) .write_le(&mut bin) .unwrap(); - for ((variable_name, series), n_strings) in source.0.iter().zip(variable_strings) { + for ((variable_name, series), n_strings) in source + .0 + .iter() + .zip(variable_strings) + .take(last_string as usize) + { (SpvString(variable_name), *n_strings as u32) .write_le(&mut bin) .unwrap(); @@ -129,6 +137,7 @@ impl DataSources { } } } + assert_eq!(n_maps, count); } (labels.len() as u32).write_le(&mut bin).unwrap(); @@ -305,6 +314,30 @@ impl DataSource { })?; Ok(()) } + + /// Parses `csv` as a `DataSource` in CSV format. + pub fn from_csv_data(csv: &[u8]) -> Result { + let mut reader = csv::Reader::from_reader(Cursor::new(&csv)); + let headers = reader.headers()?; + let mut variables = vec![DataSeries::default(); headers.len()]; + for row in reader.records() { + let row = row?; + for (column, s) in row.iter().enumerate() { + let value = match s.parse::() { + Ok(number) => Datum::Number(Some(number)), + Err(_) => Datum::String(s.into()), + }; + variables[column].data.push(value); + } + } + Ok(DataSource( + zip( + reader.headers().unwrap().iter().map(|name| name.into()), + variables, + ) + .collect(), + )) + } } /// A single column of data with an optional label. diff --git a/rust/pspp/src/spv/read/structure.rs b/rust/pspp/src/spv/read/structure.rs index ce171667d3..68093aca0b 100644 --- a/rust/pspp/src/spv/read/structure.rs +++ b/rust/pspp/src/spv/read/structure.rs @@ -474,7 +474,7 @@ mod raw { data_member: graph.data_member, csv_member: graph.csv_member, }), - graph.command_name, + graph.command_name.unwrap_or_default(), ), ContainerContent::Text(text) => ( Content::Text(Text::new( @@ -800,7 +800,7 @@ mod raw { #[serde(rename_all = "camelCase")] struct Graph { #[serde(rename = "@commandName")] - command_name: String, + command_name: Option, /// Data. #[serde(rename = "dataPath")] diff --git a/rust/pspp/src/spv/write.rs b/rust/pspp/src/spv/write.rs index a0194635c9..e697b816ba 100644 --- a/rust/pspp/src/spv/write.rs +++ b/rust/pspp/src/spv/write.rs @@ -200,6 +200,8 @@ where where X: Write, { + let graph = graph.to_spv(); + let table_id = self.next_table_id(); let xml_name = format!("{table_id:011}_chart.xml"); -- 2.47.3