]> pintos-os.org Git - pspp/commitdiff
Fix converting exported graphs back into SPV.
authorBen Pfaff <blp@cs.stanford.edu>
Mon, 27 Apr 2026 16:24:58 +0000 (09:24 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Mon, 27 Apr 2026 16:24:58 +0000 (09:24 -0700)
rust/pspp/src/spv/read/graph.rs
rust/pspp/src/spv/read/legacy_bin.rs
rust/pspp/src/spv/read/structure.rs
rust/pspp/src/spv/write.rs

index dd633ceef7350162eed73058a2121ba83f271161..bdd1f5778cb48c76e40aa5c21d5f65d66f381686 100644 (file)
@@ -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<quick_xml::DeError>),
 
-    /// 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<Self, DeError> {
+        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<Option<DataSource>, 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::<f64>() {
-                        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<Cow<'a, str>> {
+                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<F>(&self, mut warn: F) -> Result<Vec<u8>, GraphError>
     where
         F: FnMut(GraphWarning),
index 6b1313d674e9d06096f9ab39a5524bdc174f1cdb..a7d36d2a3a18e253499c6994ae8315939c1d7115 100644 (file)
@@ -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<Self, csv::Error> {
+        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::<f64>() {
+                    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.
index ce171667d33b0d94368799582695b6609956c02d..68093aca0b7100a83d42edd2887128fa3c5d12dc 100644 (file)
@@ -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<String>,
 
         /// Data.
         #[serde(rename = "dataPath")]
index a0194635c9e6c736025728fbfb8a5d6fe48c52cc..e697b816ba6124948e244d1d1ea7e9a2a6bb761a 100644 (file)
@@ -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");