]> pintos-os.org Git - pspp/commitdiff
add support for writing legacy data and a test for it
authorBen Pfaff <blp@cs.stanford.edu>
Sun, 26 Apr 2026 18:34:38 +0000 (11:34 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Sun, 26 Apr 2026 18:34:38 +0000 (11:34 -0700)
rust/pspp/src/cli/spv.rs
rust/pspp/src/file.rs
rust/pspp/src/spv/read/graph.rs
rust/pspp/src/spv/read/legacy_bin.rs
rust/pspp/src/spv/testdata/legacy-data1.bin [new file with mode: 0644]
rust/pspp/src/spv/testdata/legacy-data1.expected [new file with mode: 0644]
rust/pspp/src/spv/write.rs

index 361d0b14279831aa305aeaaad3e6dde4352db4f1..c48242aa2dd59d5c3788325db4ca7bf5e5d5eb4b 100644 (file)
 use anyhow::{Result, anyhow};
 use clap::{Args, Subcommand};
 use pspp::{
-    file::ReadSeek,
+    file::{FileType, ReadSeek},
     output::{
         Criteria, Itemlike, SpvMembers,
         pivot::{Axis3, Dimension, Group, Leaf, PivotTable, value::Value},
     },
     spv::{
         SpvArchive,
+        legacy_bin::{LegacyBin, data_pivot_table},
         read::{
-            legacy_xml::{Visualization, datum_as_format},
+            legacy_xml::Visualization,
             structure::{Content, OutlineItem},
         },
     },
@@ -122,56 +123,28 @@ pub struct LegacyData {
 
 impl LegacyData {
     fn run(self) -> Result<()> {
-        let (mut archive, items) = self.input.read_outline()?;
-        for item in items {
-            for item in item.iter_preorder() {
-                if let Some(spv_info) = item.spv_info()
-                    && let Some(members) = &spv_info.members
-                    && let Some(binary) = members.bin_member()
-                {
-                    let data = archive
-                        .read_legacy_bin(binary, &mut |w| eprintln!("{w}"))
-                        .map_err(|error| {
-                            anyhow::Error::from(error).context(format!(
-                                "While parsing {binary:?} as legacy binary SPV member"
-                            ))
-                        })?;
-
-                    let n_values = data
-                        .values()
-                        .flat_map(|map| map.values())
-                        .map(|values| values.data.len())
-                        .max()
-                        .unwrap_or(0);
-                    let index = Dimension::new(
-                        Group::new("Index")
-                            .with_multiple(Leaf::numbers(0..n_values))
-                            .with_label_shown(),
-                    );
-                    let variables = Dimension::new(Group::new("Variables").with_multiple(
-                        data.iter().map(|(name, contents)| {
-                            Group::new(name.as_str()).with_multiple(contents.keys().map(|name| {
-                                name.replace("categories", "\ncategories")
-                                    .replace("labels", "\nlabels")
-                                    .replace("group", "\ngroup")
-                                    .replace("Label", "\nLabel")
-                            }))
-                        }),
-                    ));
-                    let mut pivot_table =
-                        PivotTable::new([(Axis3::Y, index), (Axis3::X, variables)]);
-                    for (variable_index, (variable_name, values)) in
-                        data.values().flat_map(|map| map.iter()).enumerate()
+        if let Some(FileType::LegacyBin) = FileType::from_file(&self.input.input)? {
+            let data = std::fs::read(&self.input.input)?;
+            let data = LegacyBin::from_data(&data, &mut |w| eprintln!("{w}"))?;
+            println!("{}", data_pivot_table(&data));
+        } else {
+            let (mut archive, items) = self.input.read_outline()?;
+            for item in items {
+                for item in item.iter_preorder() {
+                    if let Some(spv_info) = item.spv_info()
+                        && let Some(members) = &spv_info.members
+                        && let Some(binary) = members.bin_member()
                     {
-                        for (value_index, data_value) in values.data.iter().enumerate() {
-                            let value = Value::new_datum(data_value).with_value_label(
-                                (variable_name == "cellFormat")
-                                    .then(|| datum_as_format(data_value).to_string()),
-                            );
-                            pivot_table.insert([value_index, variable_index], value);
-                        }
+                        let data = archive
+                            .read_legacy_bin(binary, &mut |w| eprintln!("{w}"))
+                            .map_err(|error| {
+                                anyhow::Error::from(error).context(format!(
+                                    "While parsing {binary:?} as legacy binary SPV member"
+                                ))
+                            })?;
+
+                        println!("{}", data_pivot_table(&data));
                     }
-                    println!("{pivot_table}");
                 }
             }
         }
index 31f0bb1a408656e45f9b34a7a1cfdb6826184115..adcfe2146cc145c83a91a44c688e49929fe488a9 100644 (file)
@@ -74,6 +74,11 @@ pub enum FileType {
 
     /// A standalone graph exported in XML format.
     Graph,
+
+    /// A legacy data binary file.
+    ///
+    /// Normally these are seen only inside SPV files.
+    LegacyBin,
 }
 
 impl Display for FileType {
@@ -208,6 +213,10 @@ impl FileType {
             return Ok(Some(Self::Viewer { encrypted: false }));
         }
 
+        if buf.starts_with(&[0, 0xaf]) || buf.starts_with(&[0, 0xb0]) {
+            return Ok(Some(Self::LegacyBin));
+        }
+
         if !buf.is_empty() && !buf.contains(&0) {
             return Ok(Some(Self::Syntax {
                 confident: buf.starts_with(b"* Encoding:"),
@@ -233,6 +242,7 @@ impl FileType {
             FileType::Look(LookFormat::Binary) => "tlo",
             FileType::Look(LookFormat::Xml) => "stt",
             FileType::Graph => "xml",
+            FileType::LegacyBin => "bin",
         }
     }
 
@@ -259,6 +269,7 @@ impl FileType {
             } => "encrypted SPSS syntax",
             FileType::Look(_) => "SPSS TableLook",
             FileType::Graph => "SPSS exported chart",
+            FileType::LegacyBin => "SPSS legacy binary data",
         }
     }
 }
index 238e1fc1d2b13d62854e88d6d327589d649ac32b..1e22f9309d7149773ce9674be342f706576b40b2 100644 (file)
@@ -488,6 +488,11 @@ impl Visualization {
         mut csv_data: Option<IndexMap<String, DataSeries>>,
         warn: &mut dyn FnMut(GraphWarning),
     ) -> Result<svg::Document, GraphError> {
+        for child in &self.children {
+            if let Some(style_cycle) = child.style_cycle() {
+                dbg!(style_cycle);
+            }
+        }
         let _styles = self.decode_styles();
         for delimited_source in self
             .children
@@ -1154,6 +1159,12 @@ impl VisChild {
             _ => None,
         }
     }
+    fn style_cycle(&self) -> Option<&StyleCycle> {
+        match self {
+            Self::StyleCycle(style_cycle) => Some(style_cycle),
+            _ => None,
+        }
+    }
 }
 
 #[derive(Deserialize, Debug)]
@@ -2380,14 +2391,14 @@ struct ValueMapEntry {
 #[derive(Clone, Deserialize, Debug)]
 #[serde(rename_all = "camelCase")]
 struct StyleCycle {
-    #[serde(default)]
+    #[serde(default, rename = "cycle")]
     cycles: Vec<Cycle>,
 }
 
 #[derive(Clone, Deserialize, Debug)]
 #[serde(rename_all = "camelCase")]
 struct Cycle {
-    #[serde(default)]
+    #[serde(default, rename = "style")]
     styles: Vec<Style>,
 }
 
@@ -2449,6 +2460,9 @@ struct Style {
     #[serde(rename = "@size")]
     size: Option<LocationLength>,
 
+    #[serde(rename = "@pattern")]
+    pattern: Option<u32>,
+
     #[serde(default, rename = "$value")]
     children: Vec<Style>,
 }
@@ -4860,6 +4874,8 @@ impl crate::output::Graph {
         visualization.decode(self.binary_data(&mut warn)?, self.csv_data()?, &mut warn)
     }
 
+    //pub fn to_spv(self) -> Self {}
+
     pub fn to_chart_xml<F>(&self, mut warn: F) -> Result<Vec<u8>, GraphError>
     where
         F: FnMut(GraphWarning),
index a6acd91d798d558975c87cf3db5b379e6b6a9a8d..7ccf3a27898ae1310b8c34e07e8d59175232afb7 100644 (file)
@@ -1,14 +1,22 @@
 //! Legacy binary data.
-use std::io::{Cursor, Read, Seek, SeekFrom};
+use core::f64;
+use std::io::{Cursor, Read, Seek, SeekFrom, Write};
 
-use binrw::{BinRead, BinResult, binread};
+use binrw::{BinRead, BinResult, BinWrite, Endian, binread, binwrite};
 use displaydoc::Display;
 use encoding_rs::UTF_8;
 use indexmap::IndexMap;
 
 use crate::{
     data::Datum,
-    spv::read::light::{U32String, parse_vec},
+    output::pivot::{Axis3, Dimension, Group, Leaf, PivotTable, value::Value},
+    spv::{
+        read::{
+            legacy_xml::datum_as_format,
+            light::{U32String, parse_vec},
+        },
+        write::SpvString,
+    },
 };
 
 /// A single column of data with an optional label.
@@ -91,6 +99,129 @@ impl LegacyBin {
         Ok(Self::read(&mut Cursor::new(&data))?.decode(warn))
     }
 
+    /// Encodes `data` into the legacy detail member format.
+    ///
+    /// # Panic
+    ///
+    /// Panics if `data` is empty.
+    pub fn encode(data: &IndexMap<String, IndexMap<String, DataSeries>>) -> Vec<u8> {
+        assert!(!data.is_empty());
+        let mut bin = Cursor::new(Vec::new());
+        bin.set_position(8);
+
+        /// Starting offset of the first metadata record.
+        const METADATA_OFFSET: usize = 8;
+        /// Size of an individual metadata record
+        const METADATA_SIZE: usize = 4 + 4 + 4 + 64 + 4;
+
+        let mut data_offset = METADATA_OFFSET + data.len() * METADATA_SIZE;
+        for (source_name, source) in data {
+            #[binwrite]
+            #[br(little)]
+            #[derive(Debug)]
+            struct Metadata<'a> {
+                n_values: u32,
+                n_variables: u32,
+                data_offset: u32,
+                #[bw(write_with(write_fixed_utf8_string), args(64))]
+                source_name: &'a str,
+                #[bw(magic(0u32))]
+                x: (),
+            }
+            let metadata = Metadata {
+                n_values: source.values().next().map_or(0, |series| series.data.len()) as u32,
+                n_variables: source.len() as u32,
+                data_offset: data_offset as u32,
+                source_name: source_name.as_str(),
+                x: (),
+            };
+            metadata.write_le(&mut bin).unwrap();
+
+            for series in source.values() {
+                data_offset += 288 + 8 * series.data.len();
+            }
+        }
+
+        let mut n_strings = Vec::with_capacity(data.len());
+        for source in data.values() {
+            let mut variable_strings = Vec::with_capacity(source.len());
+            for (variable_name, series) in source {
+                write_fixed_utf8_string(&variable_name.as_str(), &mut bin, Endian::Little, (288,))
+                    .unwrap();
+                for datum in &series.data {
+                    datum
+                        .as_number()
+                        .flatten()
+                        .unwrap_or(-f64::MAX)
+                        .write_le(&mut bin)
+                        .unwrap();
+                }
+                variable_strings.push(series.data.iter().filter(|datum| datum.is_string()).count());
+            }
+            n_strings.push(variable_strings);
+        }
+
+        let n_maps = n_strings
+            .iter()
+            .filter(|variable_strings| variable_strings.iter().any(|n| *n != 0))
+            .count() as u32;
+        let mut labels = IndexMap::<&str, u32>::new();
+        if n_maps > 0 {
+            n_maps.write_le(&mut bin).unwrap();
+            for ((source_name, source), variable_strings) in data.iter().zip(&n_strings) {
+                let Some((_n, last_string)) = variable_strings
+                    .iter()
+                    .zip(1u32..)
+                    .filter(|(n, _index)| **n > 0)
+                    .last()
+                else {
+                    continue;
+                };
+                (SpvString(source_name), last_string)
+                    .write_le(&mut bin)
+                    .unwrap();
+                for ((variable_name, series), n_strings) in source.iter().zip(variable_strings) {
+                    (SpvString(variable_name), *n_strings as u32)
+                        .write_le(&mut bin)
+                        .unwrap();
+                    for (index, datum) in series.data.iter().enumerate() {
+                        if let Some(string) = datum.as_string() {
+                            let entry = labels.entry(string.as_str());
+                            let label_index = entry.index();
+                            *entry.or_default() += 1;
+                            (index as u32, label_index as u32)
+                                .write_le(&mut bin)
+                                .unwrap();
+                        }
+                    }
+                }
+            }
+
+            (labels.len() as u32).write_le(&mut bin).unwrap();
+            for (label, frequency) in &labels {
+                (*frequency, SpvString(label)).write_le(&mut bin).unwrap();
+            }
+        }
+
+        #[binwrite]
+        #[bw(little)]
+        struct Header {
+            #[bw(magic(0u8))]
+            version: u8,
+            n_sources: u16,
+            member_size: u32,
+        }
+        let header = Header {
+            version: 0xb0,
+            n_sources: data.len() as u16,
+            member_size: bin.position() as u32,
+        };
+        bin.set_position(0);
+        header.write_le(&mut bin).unwrap();
+
+        bin.into_inner()
+    }
+
     /// Decodes legacy binary data into a map from a series name to a map of
     /// variables, which in turn contains a vector of [Datum]s.
     pub fn decode(
@@ -315,3 +446,105 @@ fn parse_fixed_utf8_string(n: usize) -> BinResult<String> {
     }
     Ok(UTF_8.decode_without_bom_handling(&buf).0.into_owned())
 }
+
+/// Writes a UTF-8 string, padded it on the right with 0-bytes to length `n`.
+fn write_fixed_utf8_string<W>(
+    s: &&str,
+    writer: &mut W,
+    _endian: Endian,
+    (n,): (usize,),
+) -> BinResult<()>
+where
+    W: Write + Seek,
+{
+    writer.write_all(&s.as_bytes()[..n.min(s.len())])?;
+    for _ in s.len()..n {
+        writer.write_all(&[0])?;
+    }
+    Ok(())
+}
+
+pub fn data_pivot_table(data: &IndexMap<String, IndexMap<String, DataSeries>>) -> PivotTable {
+    let n_values = data
+        .values()
+        .flat_map(|map| map.values())
+        .map(|values| values.data.len())
+        .max()
+        .unwrap_or(0);
+    let index = Dimension::new(
+        Group::new("Index")
+            .with_multiple(Leaf::numbers(0..n_values))
+            .with_label_shown(),
+    );
+    let variables = Dimension::new(Group::new("Variables").with_multiple(data.iter().map(
+        |(name, contents)| {
+            Group::new(name.as_str()).with_multiple(contents.keys().map(|name| {
+                name.replace("categories", "\ncategories")
+                    .replace("labels", "\nlabels")
+                    .replace("group", "\ngroup")
+                    .replace("Label", "\nLabel")
+            }))
+        },
+    )));
+    let mut pivot_table = PivotTable::new([(Axis3::Y, index), (Axis3::X, variables)]);
+    for (variable_index, (variable_name, values)) in
+        data.values().flat_map(|map| map.iter()).enumerate()
+    {
+        for (value_index, data_value) in values.data.iter().enumerate() {
+            let value = Value::new_datum(data_value).with_value_label(
+                (variable_name == "cellFormat").then(|| datum_as_format(data_value).to_string()),
+            );
+            pivot_table.insert([value_index, variable_index], value);
+        }
+    }
+    pivot_table
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{fmt::Write, path::Path};
+
+    use crate::{
+        output::pivot::tests::assert_lines_eq,
+        spv::legacy_bin::{LegacyBin, data_pivot_table},
+    };
+
+    fn test_legacy_data_file(name: &str) {
+        let input_filename = Path::new("src/spv/testdata")
+            .join(name)
+            .with_extension("bin");
+        let expected_filename = input_filename.with_extension("expected");
+        let expected = String::from_utf8(std::fs::read(&expected_filename).unwrap()).unwrap();
+
+        let mut actual = String::new();
+        let data = std::fs::read(&input_filename).unwrap();
+        let data =
+            LegacyBin::from_data(&data, &mut |w| writeln!(&mut actual, "{w}").unwrap()).unwrap();
+        write!(&mut actual, "{}", data_pivot_table(&data)).unwrap();
+
+        if expected != actual {
+            if std::env::var("PSPP_REFRESH_EXPECTED").is_ok() {
+                std::fs::write(&expected_filename, actual).unwrap();
+                panic!("{}: refreshed output", expected_filename.display());
+            } else {
+                eprintln!("note: rerun with PSPP_REFRESH_EXPECTED=1 to refresh expected output");
+            }
+        }
+        assert_lines_eq(&expected, expected_filename.display(), &actual, "actual");
+
+        let written = LegacyBin::encode(&data);
+        let mut reread = String::new();
+        let reread_data =
+            LegacyBin::from_data(&written, &mut |w| writeln!(&mut reread, "{w}").unwrap()).unwrap();
+        write!(&mut reread, "{}", data_pivot_table(&reread_data)).unwrap();
+        if expected != reread {
+            eprintln!("wrote and reread legacy data differs from expected:");
+            assert_lines_eq(&expected, expected_filename.display(), &reread, "actual");
+        }
+    }
+
+    #[test]
+    fn test_legacy_data_1() {
+        test_legacy_data_file("legacy-data1");
+    }
+}
diff --git a/rust/pspp/src/spv/testdata/legacy-data1.bin b/rust/pspp/src/spv/testdata/legacy-data1.bin
new file mode 100644 (file)
index 0000000..f9c0599
Binary files /dev/null and b/rust/pspp/src/spv/testdata/legacy-data1.bin differ
diff --git a/rust/pspp/src/spv/testdata/legacy-data1.expected b/rust/pspp/src/spv/testdata/legacy-data1.expected
new file mode 100644 (file)
index 0000000..1736e04
--- /dev/null
@@ -0,0 +1,39 @@
+╭─────┬────────────────────────────────────────────────────────────────────────────────────────────╮
+│     │                                          tableData                                         │
+│     ├──────┬──────────┬──────────┬──────────┬──────────┬───────────┬──────────┬──────────────────┤
+│     │      │          │          │          │dimension0│           │          │                  │
+│     │      │          │dimension0│dimension0│  group0  │ dimension0│dimension1│    dimension1    │
+│Index│ cell │cellFormat│categories│  group0  │   Label  │   labels  │categories│      labels      │
+├─────┼──────┼──────────┼──────────┼──────────┼──────────┼───────────┼──────────┼──────────────────┤
+│0    │  1.00│F40.0     │       .00│      2.00│Valid     │>2 years   │       .00│Frequency         │
+│1    │   .47│F40.1     │       .00│      2.00│Valid     │>2 years   │      1.00│Percent           │
+│2    │   .47│F40.1     │       .00│      2.00│Valid     │>2 years   │      2.00│Valid Percent     │
+│3    │   .47│F40.1     │       .00│      2.00│Valid     │>2 years   │      3.00│Cumulative Percent│
+│4    │ 17.00│F40.0     │      1.00│      2.00│Valid     │2-5 years  │       .00│Frequency         │
+│5    │  7.94│F40.1     │      1.00│      2.00│Valid     │2-5 years  │      1.00│Percent           │
+│6    │  7.98│F40.1     │      1.00│      2.00│Valid     │2-5 years  │      2.00│Valid Percent     │
+│7    │  8.45│F40.1     │      1.00│      2.00│Valid     │2-5 years  │      3.00│Cumulative Percent│
+│8    │122.00│F40.0     │      2.00│      2.00│Valid     │6-10 years │       .00│Frequency         │
+│9    │ 57.01│F40.1     │      2.00│      2.00│Valid     │6-10 years │      1.00│Percent           │
+│10   │ 57.28│F40.1     │      2.00│      2.00│Valid     │6-10 years │      2.00│Valid Percent     │
+│11   │ 65.73│F40.1     │      2.00│      2.00│Valid     │6-10 years │      3.00│Cumulative Percent│
+│12   │ 45.00│F40.0     │      3.00│      2.00│Valid     │11-15 years│       .00│Frequency         │
+│13   │ 21.03│F40.1     │      3.00│      2.00│Valid     │11-15 years│      1.00│Percent           │
+│14   │ 21.13│F40.1     │      3.00│      2.00│Valid     │11-15 years│      2.00│Valid Percent     │
+│15   │ 86.85│F40.1     │      3.00│      2.00│Valid     │11-15 years│      3.00│Cumulative Percent│
+│16   │ 18.00│F40.0     │      4.00│      2.00│Valid     │16-20 years│       .00│Frequency         │
+│17   │  8.41│F40.1     │      4.00│      2.00│Valid     │16-20 years│      1.00│Percent           │
+│18   │  8.45│F40.1     │      4.00│      2.00│Valid     │16-20 years│      2.00│Valid Percent     │
+│19   │ 95.31│F40.1     │      4.00│      2.00│Valid     │16-20 years│      3.00│Cumulative Percent│
+│20   │ 10.00│F40.0     │      5.00│      2.00│Valid     │>20 years  │       .00│Frequency         │
+│21   │  4.67│F40.1     │      5.00│      2.00│Valid     │>20 years  │      1.00│Percent           │
+│22   │  4.69│F40.1     │      5.00│      2.00│Valid     │>20 years  │      2.00│Valid Percent     │
+│23   │100.00│F40.1     │      5.00│      2.00│Valid     │>20 years  │      3.00│Cumulative Percent│
+│24   │213.00│F40.0     │      6.00│      2.00│Valid     │Total      │       .00│Frequency         │
+│25   │ 99.53│F40.1     │      6.00│      2.00│Valid     │Total      │      1.00│Percent           │
+│26   │100.00│F40.1     │      6.00│      2.00│Valid     │Total      │      2.00│Valid Percent     │
+│27   │  1.00│F40.0     │      7.00│      4.00│Missing   │System     │       .00│Frequency         │
+│28   │   .47│F40.1     │      7.00│      4.00│Missing   │System     │      1.00│Percent           │
+│29   │214.00│F40.0     │      8.00│      5.00│          │Total      │       .00│Frequency         │
+│30   │100.00│F40.1     │      8.00│      5.00│          │Total      │      1.00│Percent           │
+╰─────┴──────┴──────────┴──────────┴──────────┴──────────┴───────────┴──────────┴──────────────────╯
index bf27cf843dfd5df477bcd48e2024adad0027040c..a0194635c9e6c736025728fbfb8a5d6fe48c52cc 100644 (file)
@@ -969,7 +969,7 @@ impl BinWrite for SpvBool {
     }
 }
 
-struct SpvString<T>(T);
+pub struct SpvString<T>(pub T);
 impl<'a> SpvString<&'a str> {
     fn optional(s: &'a Option<String>) -> Self {
         Self(s.as_ref().map_or("", |s| s.as_str()))