work
authorBen Pfaff <blp@cs.stanford.edu>
Mon, 13 Oct 2025 02:00:15 +0000 (19:00 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Mon, 13 Oct 2025 02:00:15 +0000 (19:00 -0700)
rust/doc/src/spv/structure.md
rust/pspp/src/output.rs
rust/pspp/src/output/drivers/cairo/driver.rs
rust/pspp/src/output/pivot/look_xml.rs
rust/pspp/src/output/spv.rs
rust/pspp/src/output/spv/html.rs
rust/pspp/src/output/spv/light.rs
rust/pspp/src/show_spv.rs

index 619fa62fdefbc0228f297f3576e3ec22b9d7ec65..c1299b6cef137c7ab29e9c64e229998e37a52a46 100644 (file)
@@ -29,6 +29,10 @@ or `container` elements (or a mix), forming a tree.  In turn,
 `container` holds a `label` and one more child, usually `text` or
 `table`.
 
+<!-- toc -->
+
+## Grammar
+
 The following sections document the elements found in structure
 members in a context-free grammar-like fashion.  Consider the following
 example, which specifies the attributes and content for the `container`
@@ -174,8 +178,6 @@ information, and the CSS from the embedded HTML:
 </heading>
 ```
 
-<!-- toc -->
-
 ## The `heading` Element
 
 ```
index 5ac0a34b359544f8bca21ea3e438c34bc4bdcf4d..913c8756e58a6e86669be09b6720edd80ed6608b 100644 (file)
@@ -18,6 +18,7 @@
 use std::{
     borrow::Cow,
     collections::BTreeMap,
+    fmt::Display,
     iter::once,
     mem::take,
     str::FromStr,
@@ -53,12 +54,12 @@ pub struct Item {
     /// The localized label for the item that appears in the outline pane in the
     /// output viewer and in PDF outlines.  This is `None` if no label has been
     /// explicitly set.
-    label: Option<String>,
+    pub label: Option<String>,
 
     /// A locale-invariant identifier for the command that produced the output,
     /// which may be `None` if unknown or if a command did not produce this
     /// output.
-    command_name: Option<String>,
+    pub command_name: Option<String>,
 
     /// For a heading item, this is true if the heading's subtree should
     /// be expanded in an outline view, false otherwise.
@@ -66,15 +67,15 @@ pub struct Item {
     /// For other kinds of output items, this is true to show the item's
     /// content, false to hide it.  The item's label is always shown in an
     /// outline view.
-    show: bool,
+    pub show: bool,
 
     /// Item details.
-    details: Details,
+    pub details: Details,
 
     /// If the item was read from an SPV file, this is additional information
     /// related to that file.
     #[serde(skip_serializing)]
-    spv_info: Option<Box<SpvInfo>>,
+    pub spv_info: Option<Box<SpvInfo>>,
 }
 
 impl Item {
@@ -89,17 +90,6 @@ impl Item {
         }
     }
 
-    /// Returns a new heading item suitable as the root node of an output document.
-    ///
-    /// A root node is a heading whose own properties are mostly disregarded.
-    /// Instead of having root nodes, it would make just as much sense to just
-    /// keep around arrays of nodes that would serve as the top level of an
-    /// output document, but we'd need more special cases instead of just using
-    /// the existing support for heading items.
-    pub fn new_root() -> Self {
-        Self::new(Details::Heading(Heading(Vec::new()))).with_label(Some(String::from("Output")))
-    }
-
     pub fn label(&self) -> Cow<'static, str> {
         match &self.label {
             Some(label) => Cow::from(label.clone()),
@@ -107,12 +97,21 @@ impl Item {
         }
     }
 
+    pub fn subtype(&self) -> Option<String> {
+        self.details
+            .as_table()
+            .map(|table| table.subtype().display(table).to_string())
+    }
+
     pub fn with_show(self, show: bool) -> Self {
         Self { show, ..self }
     }
 
-    pub fn with_label(self, label: Option<String>) -> Self {
-        Self { label, ..self }
+    pub fn with_label(self, label: impl Into<String>) -> Self {
+        Self {
+            label: Some(label.into()),
+            ..self
+        }
     }
 
     pub fn with_command_name(self, command_name: Option<String>) -> Self {
@@ -122,6 +121,10 @@ impl Item {
         }
     }
 
+    pub fn with_some_command_name(self, command_name: impl Into<String>) -> Self {
+        self.with_command_name(Some(command_name.into()))
+    }
+
     pub fn with_spv_info(self, spv_info: SpvInfo) -> Self {
         Self {
             spv_info: Some(Box::new(spv_info)),
@@ -175,6 +178,37 @@ impl Heading {
     }
 }
 
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum ItemKind {
+    Chart,
+    Image,
+    Heading,
+    Message,
+    PageBreak,
+    Table,
+    Text,
+}
+
+impl ItemKind {
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            ItemKind::Chart => "chart",
+            ItemKind::Image => "image",
+            ItemKind::Heading => "heading",
+            ItemKind::Message => "message",
+            ItemKind::PageBreak => "page break",
+            ItemKind::Table => "table",
+            ItemKind::Text => "text",
+        }
+    }
+}
+
+impl Display for ItemKind {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.as_str())
+    }
+}
+
 #[derive(Clone, Debug, Serialize)]
 pub enum Details {
     Chart,
@@ -269,6 +303,18 @@ impl Details {
     pub fn is_text(&self) -> bool {
         matches!(self, Self::Text(_))
     }
+
+    pub fn kind(&self) -> ItemKind {
+        match self {
+            Details::Chart => ItemKind::Chart,
+            Details::Image => ItemKind::Image,
+            Details::Heading(_) => ItemKind::Heading,
+            Details::Message(_) => ItemKind::Message,
+            Details::PageBreak => ItemKind::PageBreak,
+            Details::Table(_) => ItemKind::Table,
+            Details::Text(_) => ItemKind::Text,
+        }
+    }
 }
 
 impl<A> FromIterator<A> for Details
@@ -329,12 +375,15 @@ pub struct Text {
 }
 
 impl Text {
-    pub fn new_log(value: impl Into<Value>) -> Self {
+    pub fn new(type_: TextType, content: impl Into<Value>) -> Self {
         Self {
-            type_: TextType::Log,
-            content: value.into(),
+            type_,
+            content: content.into(),
         }
     }
+    pub fn new_log(content: impl Into<Value>) -> Self {
+        Self::new(TextType::Log, content)
+    }
 
     pub fn into_item(self) -> Item {
         Details::Text(Box::new(self)).into_item()
@@ -758,7 +807,7 @@ pub struct Criteria(pub Vec<Selection>);
 impl Criteria {
     /// Returns a new output item whose children are all the (direct and
     /// indirect) children of `item` that meet the criteria.
-    fn apply(&self, item: Item) -> Item {
+    pub fn apply(&self, item: Item) -> Item {
         fn take_children(item: &Item) -> Vec<&Item> {
             item.details.children().iter().map(|item| &**item).collect()
         }
@@ -875,7 +924,7 @@ impl Criteria {
             }
         }
         fn unflatten_item(mut item: Item, include: &mut bit_vec::Iter, out: &mut Vec<Arc<Item>>) {
-            let include_item = include.next().unwrap();
+            let include_item = include.next().unwrap_or_default(); //XXX should just be unwrap
             if let Some(children) = item.details.mut_children() {
                 if !include_item {
                     unflatten_items(take(children), include, out);
@@ -901,13 +950,9 @@ impl Criteria {
             select_matches(&items, selection, &mut include);
         }
 
-        let mut output = Item::new_root();
-        unflatten_item(
-            item,
-            &mut include.iter(),
-            output.details.mut_children().unwrap(),
-        );
-        output
+        let mut output = Vec::new();
+        unflatten_item(item, &mut include.iter(), &mut output);
+        Heading(output).into_item().with_label("Output")
     }
 }
 
@@ -934,50 +979,43 @@ impl FromArgMatches for Criteria {
 
         fn extract<F, T: Clone + Send + Sync + 'static>(
             matches: &ArgMatches,
-            id: &clap::Id,
+            id: &str,
             output: &mut BTreeMap<usize, Value>,
             f: F,
         ) where
             F: Fn(T) -> Value,
         {
+            if !matches.contains_id(id) || matches.try_get_many::<clap::Id>(id).is_ok() {
+                // ignore groups
+                return;
+            }
+            let value_source = matches.value_source(id).expect("id came from matches");
+            if value_source != clap::parser::ValueSource::CommandLine {
+                // Any other source just gets tacked on at the end (like default values)
+                return;
+            }
             for (value, index) in matches
-                .try_get_many::<T>(id.as_str())
+                .try_get_many::<T>(id)
                 .unwrap()
                 .unwrap()
-                .zip(matches.indices_of(id.as_str()).unwrap())
+                .zip(matches.indices_of(id).unwrap())
             {
                 output.insert(index, f(value.clone()));
             }
         }
 
         let mut values = BTreeMap::new();
-        for id in matches.ids() {
-            if matches.try_get_many::<clap::Id>(id.as_str()).is_ok() {
-                // ignore groups
-                continue;
-            }
-            let value_source = matches
-                .value_source(id.as_str())
-                .expect("id came from matches");
-            if value_source != clap::parser::ValueSource::CommandLine {
-                // Any other source just gets tacked on at the end (like default values)
-                continue;
-            }
-            match id.as_str() {
-                "_or" => extract(matches, id, &mut values, |_: bool| Value::Or),
-                "select" => extract(matches, id, &mut values, Value::Classes),
-                "commands" => extract(matches, id, &mut values, Value::Commands),
-                "subtypes" => extract(matches, id, &mut values, Value::Subtypes),
-                "labels" => extract(matches, id, &mut values, Value::Labels),
-                "nth-commands" => extract(matches, id, &mut values, Value::NthCommands),
-                "instances" => extract(matches, id, &mut values, Value::Instances),
-                "show-hidden" => extract(matches, id, &mut values, Value::ShowHidden),
-                "errors" => extract(matches, id, &mut values, Value::Errors),
-                _ => unreachable!("{id}"),
-            }
-        }
-
-        if !self.0.is_empty() {
+        extract(matches, "_or", &mut values, |_: bool| Value::Or);
+        extract(matches, "select", &mut values, Value::Classes);
+        extract(matches, "commands", &mut values, Value::Commands);
+        extract(matches, "subtypes", &mut values, Value::Subtypes);
+        extract(matches, "labels", &mut values, Value::Labels);
+        extract(matches, "nth_commands", &mut values, Value::NthCommands);
+        extract(matches, "instances", &mut values, Value::Instances);
+        extract(matches, "show_hidden", &mut values, Value::ShowHidden);
+        extract(matches, "errors", &mut values, Value::Errors);
+
+        if !values.is_empty() {
             let mut selection = Selection::default();
             for value in values.into_values() {
                 match value {
@@ -1066,7 +1104,9 @@ mod tests {
     use clap::Parser;
     use enumset::EnumSet;
 
-    use crate::output::{Class, Criteria, Selection, StringMatch};
+    use crate::output::{
+        Class, Criteria, Heading, Item, Selection, StringMatch, pivot::PivotTable,
+    };
 
     #[test]
     fn parse_classes() {
@@ -1159,9 +1199,44 @@ mod tests {
         );
     }
 
-    #[test]
-    fn apply_criteria() {
-        //let item = Details::Group();
-        todo!()
+    fn regress_item() -> Item {
+        [
+            Heading::new()
+                .into_item()
+                .with_label("Set")
+                .with_some_command_name("Set"),
+            [Heading::new()
+                .into_item()
+                .with_label("Page Title")
+                .with_some_command_name("Title")]
+            .into_iter()
+            .collect::<Item>()
+            .with_label("Title")
+            .with_some_command_name("Title"),
+            [PivotTable::new([])
+                .with_title("Reading 1 record from INLINE.")
+                .with_subtype("Fixed Data Records")
+                .into_item()
+                .with_some_command_name("Data List")]
+            .into_iter()
+            .collect::<Item>()
+            .with_label("Data List")
+            .with_some_command_name("Data List"),
+            Heading::new()
+                .into_item()
+                .with_label("Begin Data")
+                .with_some_command_name("Begin Data"),
+            [PivotTable::new([])
+                .with_title("Data List")
+                .into_item()
+                .with_some_command_name("List")]
+            .into_iter()
+            .collect::<Item>()
+            .with_label("List")
+            .with_some_command_name("List"),
+        ]
+        .into_iter()
+        .collect::<Item>()
+        .with_label("Output")
     }
 }
index b676ce0919d34722eacfc8b5ec909eb4dcd37942..31705f99ae0130eaf8fef44419ebaf1635ffa0a6 100644 (file)
@@ -130,23 +130,18 @@ impl Driver for CairoDriver {
             pager
         });
         pager.add_item(item.clone());
-        dbg!();
         while pager.needs_new_page() {
-            dbg!();
             pager.finish_page();
             let context = Context::new(&self.surface).unwrap();
             context.show_page().unwrap();
             pager.add_page(context);
         }
-        dbg!();
     }
 }
 
 impl Drop for CairoDriver {
     fn drop(&mut self) {
-        dbg!();
         if self.pager.is_some() {
-            dbg!();
             let context = Context::new(&self.surface).unwrap();
             context.show_page().unwrap();
         }
index 9a00bfe1c92bae83ce1c1cec54a6e8d3f350716d..d9e59ee56ab89d0503249da77898a488efde230f 100644 (file)
@@ -542,5 +542,6 @@ mod tests {
 "##;
         let table_properties: TableProperties = from_str(XML).unwrap();
         dbg!(&table_properties);
+        todo!()
     }
 }
index 3125df475a18f99f31882f2c3120d1c556fb703f..a212bde8f6946ba2e26456181ed85b0a7f27957b 100644 (file)
@@ -58,11 +58,11 @@ pub enum Error {
 }
 
 impl Item {
-    fn from_spv_file(path: impl AsRef<Path>) -> Result<(Self, Option<PageSetup>), Error> {
+    pub fn from_spv_file(path: impl AsRef<Path>) -> Result<(Self, Option<PageSetup>), Error> {
         Self::from_spv_reader(File::open(path.as_ref())?)
     }
 
-    fn from_spv_zip_archive<R>(
+    pub fn from_spv_zip_archive<R>(
         archive: &mut ZipArchive<R>,
     ) -> Result<(Self, Option<PageSetup>), Error>
     where
@@ -93,7 +93,7 @@ impl Item {
         Ok((items.into_iter().collect(), page_setup))
     }
 
-    fn from_spv_reader<R>(reader: R) -> Result<(Self, Option<PageSetup>), Error>
+    pub fn from_spv_reader<R>(reader: R) -> Result<(Self, Option<PageSetup>), Error>
     where
         R: Read + Seek,
     {
@@ -114,7 +114,6 @@ fn read_heading<R>(
 where
     R: Read + Seek,
 {
-    println!("{structure_member}");
     let member = BufReader::new(archive.by_index(file_number)?);
     let mut heading: Heading = match serde_path_to_error::deserialize(
         &mut quick_xml::de::Deserializer::from_reader(member),
@@ -122,7 +121,6 @@ where
         Ok(result) => result,
         Err(error) => panic!("{error}"),
     };
-    dbg!(&heading);
     let page_setup = heading.page_setup.take();
     Ok((heading.decode(archive, structure_member)?, page_setup))
 }
@@ -132,6 +130,8 @@ where
 struct Heading {
     #[serde(rename = "@visibility")]
     visibility: Option<String>,
+    #[serde(rename = "@commandName")]
+    command_name: Option<String>,
     label: Label,
     page_setup: Option<PageSetup>,
 
@@ -168,22 +168,35 @@ impl Heading {
                         }
                         ContainerContent::Text(container_text) => {
                             items.push(
-                                Text::new_log(container_text.decode())
-                                    .into_item()
-                                    .with_command_name(container_text.command_name)
-                                    .with_spv_info(SpvInfo::new(structure_member)),
+                                Text::new(
+                                    match container_text.text_type {
+                                        TextType::Title => crate::output::TextType::Title,
+                                        TextType::Log | TextType::Text => {
+                                            crate::output::TextType::Log
+                                        }
+                                        TextType::PageTitle => crate::output::TextType::PageTitle,
+                                    },
+                                    container_text.decode(),
+                                )
+                                .into_item()
+                                .with_command_name(container_text.command_name)
+                                .with_spv_info(SpvInfo::new(structure_member)),
                             );
                         }
                     }
                 }
-                HeadingContent::Heading(heading) => {
+                HeadingContent::Heading(mut heading) => {
                     let show = !heading.visibility.is_some();
+                    let label = std::mem::take(&mut heading.label.text);
+                    let command_name = heading.command_name.take();
                     items.push(
                         heading
                             .decode(archive, structure_member)?
                             .into_iter()
                             .collect::<Item>()
                             .with_show(show)
+                            .with_label(label)
+                            .with_command_name(command_name)
                             .with_spv_info(SpvInfo::new(structure_member)),
                     );
                 }
@@ -279,7 +292,6 @@ impl Table {
             light.read_to_end(&mut data)?;
             let table = LightTable::read(&mut Cursor::new(data))?;
             let pivot_table = table.decode()?;
-            println!("{}", &pivot_table);
             Ok(pivot_table.into_item().with_spv_info(
                 SpvInfo::new(structure_member)
                     .with_members(SpvMembers::Light(self.table_structure.data_path.clone())),
@@ -339,4 +351,5 @@ fn test_spv() {
         .unwrap()
         .0;
     println!("{item}");
+    todo!()
 }
index e0aed0701f14963ffc1275115853c8590517910e..06bc616ac7d108b772c0846617b55fe12cc3e941 100644 (file)
@@ -16,7 +16,6 @@ fn find_element<'a>(elements: &'a [Node], name: &str) -> Option<&'a Element> {
         if let Node::Element(element) = element
             && element.name == name
         {
-            dbg!(element);
             return Some(element);
         }
     }
@@ -404,6 +403,7 @@ mod tests {
         );
         dbg!(&paragraphs);
         assert_eq!(paragraphs.len(), 5);
+        todo!()
         /*
         assert_eq!(
             paragraph,
index 348932137ec539f186bf67ecf37644b540c488bb..89c0b289865176a1715c53b043625d4140d78ef1 100644 (file)
@@ -47,7 +47,6 @@ pub enum LightError {
 #[br(little)]
 #[derive(Debug)]
 pub struct LightTable {
-    #[br(dbg)]
     header: Header,
     #[br(args(header.version))]
     titles: Titles,
@@ -66,7 +65,7 @@ pub struct LightTable {
     #[br(parse_with(parse_counted), args(header.version))]
     dimensions: Vec<Dimension>,
     axes: Axes,
-    #[br(dbg, parse_with(parse_counted), args(header.version))]
+    #[br(parse_with(parse_counted), args(header.version))]
     cells: Vec<Cell>,
 }
 
@@ -363,7 +362,6 @@ fn parse_color() -> BinResult<Color> {
     let pos = reader.stream_position()?;
     let string = U32String::read_options(reader, endian, ())?;
     let string = string.decode(WINDOWS_1252);
-    dbg!(&string);
     if string.is_empty() {
         Ok(Color::BLACK)
     } else {
@@ -563,7 +561,6 @@ struct PrintSettings {
 struct TableSettings {
     #[br(temp, magic = 1u32)]
     _x5: i32,
-    #[br(dbg)]
     current_layer: i32,
     #[br(parse_with(parse_bool))]
     omit_empty: bool,
@@ -651,7 +648,6 @@ impl BinRead for Value {
         (version,): (Version,),
     ) -> BinResult<Self> {
         let start = reader.stream_position()?;
-        dbg!(start);
         for i in 0..4 {
             let x = <u8>::read_options(reader, endian, ())?;
             if x != 0 {
@@ -659,11 +655,7 @@ impl BinRead for Value {
                 break;
             }
         }
-        Ok(Value(dbg!(RawValue::read_options(
-            reader,
-            endian,
-            (version,)
-        ))?))
+        Ok(Value(RawValue::read_options(reader, endian, (version,))?))
     }
 }
 
@@ -805,7 +797,6 @@ where
 struct Formats {
     #[br(parse_with(parse_counted))]
     column_widths: Vec<i32>,
-    #[br(dbg)]
     locale: U32String,
     current_layer: i32,
     #[br(temp, parse_with(parse_bool))]
@@ -814,7 +805,6 @@ struct Formats {
     _x8: bool,
     #[br(temp, parse_with(parse_bool))]
     _x9: bool,
-    #[br(dbg)]
     y0: Y0,
     custom_currency: CustomCurrency,
     #[br(if(version == Version::V1))]
index 3a680c2dd2a47f310a72b883388788bf4532fd7c..fafe045eacb275259cd353ef3602f711b30965d1 100644 (file)
@@ -16,7 +16,7 @@
 
 use anyhow::Result;
 use clap::{Args, ValueEnum};
-use pspp::output::Criteria;
+use pspp::output::{Criteria, Item};
 use std::{fmt::Display, path::PathBuf};
 
 /// Show information about SPSS viewer files (SPV files).
@@ -35,7 +35,11 @@ pub struct ShowSpv {
 
     /// Input selection options.
     #[command(flatten)]
-    selection: Criteria,
+    criteria: Criteria,
+
+    /// Include ZIP member names in `dir` output.
+    #[arg(long = "member-names")]
+    show_member_names: bool,
 }
 
 /// What to show in a system file.
@@ -70,7 +74,54 @@ impl Display for Mode {
 
 impl ShowSpv {
     pub fn run(self) -> Result<()> {
-        println!("{:#?}", &self);
-        todo!()
+        match self.mode {
+            Mode::Directory => {
+                let item = Item::from_spv_file(&self.input)?.0;
+                //let item = self.criteria.apply(item);
+                for child in item.details.children() {
+                    print_item_directory(&child, 0, self.show_member_names);
+                }
+                Ok(())
+            }
+            Mode::GetTableLook => todo!(),
+            Mode::ConvertTableLook => todo!(),
+        }
+    }
+}
+
+fn print_item_directory(item: &Item, level: usize, show_member_names: bool) {
+    for _ in 0..level {
+        print!("    ");
+    }
+    print!("- {} {:?}", item.details.kind(), item.label());
+    if let Some(table) = item.details.as_table() {
+        let title = table.title().display(table).to_string();
+        if item.label.as_ref().is_none_or(|label| label != &title) {
+            print!(" title {title:?}");
+        }
+    }
+    if let Some(command_name) = &item.command_name {
+        print!(" command {command_name:?}");
+    }
+    if let Some(subtype) = item.subtype()
+        && item.label.as_ref().is_none_or(|label| label != &subtype)
+    {
+        print!(" subtype {subtype:?}");
+    }
+    if !item.show {
+        if item.details.is_heading() {
+            print!(" (collapsed)");
+        } else {
+            print!(" (hidden)");
+        }
+    }
+    if show_member_names && let Some(spv_info) = &item.spv_info {
+        for (index, name) in spv_info.member_names().into_iter().enumerate() {
+            print!(" {} {name:?}", if index == 0 { "in" } else { "and" });
+        }
+    }
+    println!();
+    for child in item.details.children() {
+        print_item_directory(&child, level + 1, show_member_names);
     }
 }