table output is starting to work
authorBen Pfaff <blp@cs.stanford.edu>
Thu, 15 May 2025 23:55:45 +0000 (16:55 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Thu, 15 May 2025 23:55:45 +0000 (16:55 -0700)
rust/doc/src/spv/index.md
rust/doc/src/spv/light-detail.md
rust/pspp/src/output/mod.rs
rust/pspp/src/output/pivot/test.rs
rust/pspp/src/output/spv.rs

index 28f985221e771b127beaca04d3bd951b983f408a..158e40e87956ea513d6425b1fbdb5c92cfafa55a 100644 (file)
@@ -25,12 +25,12 @@ start the same way.
 
 > SPSS writes `META-INF/MANIFEST.MF` to every SPV file, but it does
 not read it or even require it to exist, so using different contents,
-e.g. as `allowingPivot=false` has no effect.
+e.g. `allowPivoting=false`, has no effect.
 
 The rest of the members in an SPV file's Zip archive fall into two
 categories: "structure" and "detail" members.  Structure member names
 take the form with `outputViewerNUMBER.xml` or
-`outputViewerNUMBER_heading.xml`, where NUMBER is an 10-digit decimal
+`outputViewerNUMBER_heading.xml`, where `NUMBER` is an 10-digit decimal
 number.  Each of these members represents some kind of output item (a
 table, a heading, a block of text, etc.)  or a group of them.  The
 member whose output goes at the beginning of the document is numbered
index 6b70c7be58acaa07d13da55c6dc8167843368807..16f1ae8f27846de91a30308f0fb98f5df8cb82f0 100644 (file)
@@ -1,6 +1,11 @@
 # Light Detail Member Format
 
 This section describes the format of "light" detail `.bin` members.
+
+<!-- toc -->
+
+## Binary Format Conventions
+
 These members have a binary format which we describe here in terms of a
 context-free grammar using the following conventions:
 
@@ -110,10 +115,6 @@ Table =>
     01?
 ```
 
-The following sections go into more detail.
-
-<!-- toc -->
-
 ## Header
 
 An SPV light member begins with a 39-byte header:
index ea3b4274eec085034ea62b86d3aa58522e39fbb3..6eadd671e7022eb9a11dc46aaea8db8b05e41a8e 100644 (file)
@@ -102,6 +102,13 @@ impl Details {
             Details::Text(text) => Cow::from(text.type_.as_str()),
         }
     }
+
+    pub fn is_page_break(&self) -> bool {
+        match self {
+            Self::PageBreak => true,
+            _ => false,
+        }
+    }
 }
 
 pub struct Text {
index eab8653300f2a001fe9a00f57e4795d06c6a14f1..1354a66d6bba531a4fb45c2f2f6bba0a8ec9ff2c 100644 (file)
@@ -11,6 +11,7 @@ use crate::output::{
         FootnoteMarkerPosition, FootnoteMarkerType, Footnotes, Group, HeadingRegion, LabelPosition,
         Look, PivotTable, RowColBorder, Stroke,
     },
+    spv::SpvDriver,
     Details, Item,
 };
 
@@ -136,17 +137,21 @@ fn assert_rendering(name: &str, pivot_table: &PivotTable, expected: &str) {
         panic!();
     }
 
+    let item = Arc::new(Item::new(Details::Table(Box::new(pivot_table.clone()))));
     if let Some(dir) = std::env::var_os("PSPP_TEST_HTML_DIR") {
         let writer = File::create(Path::new(&dir).join(name).with_extension("html")).unwrap();
-        let mut driver = HtmlRenderer::new(writer);
-        let item = Arc::new(Item::new(Details::Table(Box::new(pivot_table.clone()))));
-        driver.write(&item);
+        HtmlRenderer::new(writer).write(&item);
     }
 
+    let item = Arc::new(Item::new(Details::Table(Box::new(pivot_table.clone()))));
     if let Some(dir) = std::env::var_os("PSPP_TEST_PDF_DIR") {
-        let mut driver = CairoDriver::new(Path::new(&dir).join(name).with_extension("pdf"));
-        let item = Arc::new(Item::new(Details::Table(Box::new(pivot_table.clone()))));
-        driver.write(&item);
+        let path = Path::new(&dir).join(name).with_extension("pdf");
+        CairoDriver::new(path).write(&item);
+    }
+
+    if let Some(dir) = std::env::var_os("PSPP_TEST_SPV_DIR") {
+        let writer = File::create(Path::new(&dir).join(name).with_extension("spv")).unwrap();
+        SpvDriver::new(writer).write(&item);
     }
 }
 
index df39a7126c07a884de0422449a888df24b6c4054..c3293f24794ef2bc0352ddb76dd5f81c3ad3ce2a 100644 (file)
@@ -8,6 +8,7 @@ use std::{
 };
 
 use binrw::{BinWrite, Endian};
+use chrono::Utc;
 use enum_map::EnumMap;
 use quick_xml::{
     events::{attributes::Attribute, BytesText},
@@ -34,27 +35,41 @@ use crate::{
 };
 
 fn light_table_name(table_id: u64) -> String {
-    format!("{:010}_lightTableData.bin", table_id)
+    format!("{table_id:011}_lightTableData.bin")
 }
 
-pub struct SpvWriter<W>
+fn output_viewer_name(heading_id: u64, is_heading: bool) -> String {
+    format!(
+        "outputViewer{heading_id:010}{}.xml",
+        if is_heading { "_heading" } else { "" }
+    )
+}
+
+pub struct SpvDriver<W>
 where
     W: Write + Seek,
 {
     writer: ZipWriter<W>,
     needs_page_break: bool,
     next_table_id: u64,
+    next_heading_id: u64,
 }
 
-impl<W> SpvWriter<W>
+impl<W> SpvDriver<W>
 where
     W: Write + Seek,
 {
     pub fn new(writer: W) -> Self {
+        let mut writer = ZipWriter::new(writer);
+        writer
+            .start_file("META-INF/MANIFEST.MF", SimpleFileOptions::default())
+            .unwrap();
+        writer.write_all("allowPivoting=true".as_bytes()).unwrap();
         Self {
-            writer: ZipWriter::new(writer),
+            writer,
             needs_page_break: false,
             next_table_id: 1,
+            next_heading_id: 1,
         }
     }
 
@@ -71,56 +86,115 @@ where
         page_break_before
     }
 
-    fn write_table(&mut self, item: &Item, pivot_table: &PivotTable) -> Container {
+    fn write_table<X>(
+        &mut self,
+        item: &Item,
+        pivot_table: &PivotTable,
+        structure: &mut XmlWriter<X>,
+    ) where
+        X: Write,
+    {
         let table_id = self.next_table_id;
         self.next_table_id += 1;
 
         let mut content = Vec::new();
         let mut cursor = Cursor::new(&mut content);
-        pivot_table.write_be(&mut cursor).unwrap();
+        pivot_table.write_le(&mut cursor).unwrap();
 
+        let table_name = light_table_name(table_id);
         self.writer
-            .start_file(light_table_name(table_id), SimpleFileOptions::default())
+            .start_file(&table_name, SimpleFileOptions::default())
             .unwrap(); // XXX
         self.writer.write_all(&content).unwrap(); // XXX
 
-        Container {
-            page_break_before: self.page_break_before(),
-            label: Label(item.label().into_owned()),
-            show: item.show,
-            command_name: item.command_name.clone(),
-            content: Content::Table(Table {
-                table_properties: None,
-                table_structure: TableStructure,
-                table_id,
-                subtype: match &pivot_table.subtype {
-                    Some(subtype) => subtype.display(pivot_table).to_string(),
-                    None => String::from("unknown"),
-                },
-            }),
-        }
+        self.container(structure, item, "vtb:table", |element| {
+            element
+                .with_attribute(("tableId", Cow::from(table_id.to_string())))
+                .with_attribute((
+                    "subType",
+                    Cow::from(pivot_table.subtype().display(pivot_table).to_string()),
+                ))
+                .write_inner_content(|w| {
+                    w.create_element("vtb:tableStructure")
+                        .write_inner_content(|w| {
+                            w.create_element("vtb:dataPath")
+                                .write_text_content(BytesText::new(&table_name))?;
+                            Ok(())
+                        })?;
+                    Ok(())
+                })
+                .unwrap();
+        });
     }
 
-    fn write_item(&mut self, item: &Item) -> Option<Container> {
+    fn write_item<X>(&mut self, item: &Item, structure: &mut XmlWriter<X>)
+    where
+        X: Write,
+    {
         match &item.details {
             super::Details::Chart => todo!(),
             super::Details::Image => todo!(),
             super::Details::Group(children) => {
-                let containers = children
-                    .iter()
-                    .map(|child| self.write_item(child))
-                    .flatten()
-                    .collect::<Vec<_>>();
+                let mut attributes = Vec::<Attribute>::new();
+                if let Some(command_name) = &item.command_name {
+                    attributes.push((("commandName", command_name.as_str())).into());
+                }
+                if !item.show {
+                    attributes.push(("visibility", "collapsed").into());
+                }
+                structure
+                    .create_element("heading")
+                    .with_attributes(attributes)
+                    .write_inner_content(|w| {
+                        w.create_element("label")
+                            .write_text_content(BytesText::new(&item.label()))?;
+                        for child in children {
+                            self.write_item(child, w);
+                        }
+                        Ok(())
+                    })
+                    .unwrap();
             }
             super::Details::Message(_diagnostic) => todo!(),
             super::Details::PageBreak => {
                 self.needs_page_break = true;
-                None
             }
-            super::Details::Table(pivot_table) => Some(self.write_table(&*item, pivot_table)),
+            super::Details::Table(pivot_table) => self.write_table(&*item, pivot_table, structure),
             super::Details::Text(_text) => todo!(),
         }
     }
+
+    fn container<'a, X, F>(
+        &mut self,
+        writer: &'a mut XmlWriter<X>,
+        item: &Item,
+        inner_elem: &str,
+        closure: F,
+    ) where
+        X: Write,
+        F: FnOnce(ElementWriter<X>),
+    {
+        writer
+            .create_element("container")
+            .with_attributes(
+                self.page_break_before()
+                    .then_some(("page-break-before", "always")),
+            )
+            .with_attribute(("visibility", if item.show { "visible" } else { "hidden" }))
+            .write_inner_content(|w| {
+                let mut element = w
+                    .create_element("label")
+                    .write_text_content(BytesText::new(&item.label()))
+                    .unwrap()
+                    .create_element(inner_elem);
+                if let Some(command_name) = &item.command_name {
+                    element = element.with_attribute(("commandName", command_name.as_str()));
+                };
+                closure(element);
+                Ok(())
+            })
+            .unwrap();
+    }
 }
 
 impl BinWrite for PivotTable {
@@ -134,17 +208,20 @@ impl BinWrite for PivotTable {
     ) -> binrw::BinResult<()> {
         // Header.
         (
-            1u16,
+            1u8,
+            0u8,
+            3u32,           // version
             SpvBool(true),  // x0
             SpvBool(false), // x1
             SpvBool(self.rotate_inner_column_labels),
             SpvBool(self.rotate_outer_row_labels),
-            SpvBool(true),
-            0x15u32,
+            SpvBool(true), // x2
+            0x15u32,       // x3
             *self.look.heading_widths[HeadingRegion::Columns].start() as i32,
             *self.look.heading_widths[HeadingRegion::Columns].end() as i32,
             *self.look.heading_widths[HeadingRegion::Rows].start() as i32,
             *self.look.heading_widths[HeadingRegion::Rows].end() as i32,
+            0u64,
         )
             .write_le(writer)?;
 
@@ -333,13 +410,13 @@ impl BinWrite for PivotTable {
         (
             0u32,
             SpvString("en_US.ISO_8859-1:1987"),
-            0u32,
-            SpvBool(false),
-            SpvBool(false),
-            SpvBool(true),
+            0u32,           // XXX current_layer
+            SpvBool(false), // x7
+            SpvBool(false), // x8
+            SpvBool(false), // x9
             y0(self),
             custom_currency(self),
-            Counted::new(Counted::new(((x1(self), x2()), x3(self)))),
+            Counted::new((Counted::new((x1(self), x2())), x3(self))),
         )
             .write_le(writer)?;
 
@@ -387,7 +464,7 @@ impl PivotTable {
     }
 }
 
-impl<W> Driver for SpvWriter<W>
+impl<W> Driver for SpvDriver<W>
 where
     W: Write + Seek,
 {
@@ -396,7 +473,54 @@ where
     }
 
     fn write(&mut self, item: &Arc<Item>) {
-        if let Some(container) = self.write_item(item) {}
+        if item.details.is_page_break() {
+            self.needs_page_break = true;
+            return;
+        }
+
+        let mut headings = XmlWriter::new(Cursor::new(Vec::new()));
+        let element = headings
+            .create_element("heading")
+            .with_attribute((
+                "creation-date-time",
+                Cow::from(Utc::now().format("%x %x").to_string()),
+            ))
+            .with_attribute((
+                "creator",
+                Cow::from(format!(
+                    "{} {}",
+                    env!("CARGO_PKG_NAME"),
+                    env!("CARGO_PKG_VERSION")
+                )),
+            ))
+            .with_attribute(("creator-version", "21"))
+            .with_attribute(("xmlns", "http://xml.spss.com/spss/viewer/viewer-tree"))
+            .with_attribute((
+                "xmlns:vps",
+                "http://xml.spss.com/spss/viewer/viewer-pagesetup",
+            ))
+            .with_attribute(("xmlns:vtx", "http://xml.spss.com/spss/viewer/viewer-text"))
+            .with_attribute(("xmlns:vtb", "http://xml.spss.com/spss/viewer/viewer-table"));
+        element
+            .write_inner_content(|w| {
+                w.create_element("label")
+                    .write_text_content(BytesText::new("Output"))?;
+                // XXX page setup
+                self.write_item(item, w);
+                Ok(())
+            })
+            .unwrap();
+
+        let headings = headings.into_inner().into_inner();
+        let heading_id = self.next_heading_id;
+        self.next_heading_id += 1;
+        self.writer
+            .start_file(
+                output_viewer_name(heading_id, item.details.as_group().is_some()),
+                SimpleFileOptions::default(),
+            )
+            .unwrap(); // XXX
+        self.writer.write_all(&headings).unwrap(); // XXX
     }
 }
 
@@ -1053,12 +1177,12 @@ where
     }
 }
 
-struct OptionalStyle<'a> {
+struct ValueMod<'a> {
     style: &'a Option<Box<ValueStyle>>,
     template: Option<&'a str>,
 }
 
-impl<'a> OptionalStyle<'a> {
+impl<'a> ValueMod<'a> {
     fn new(value: &'a Value) -> Self {
         Self {
             style: &value.styling,
@@ -1067,7 +1191,7 @@ impl<'a> OptionalStyle<'a> {
     }
 }
 
-impl<'a> Default for OptionalStyle<'a> {
+impl<'a> Default for ValueMod<'a> {
     fn default() -> Self {
         Self {
             style: &None,
@@ -1076,7 +1200,7 @@ impl<'a> Default for OptionalStyle<'a> {
     }
 }
 
-impl<'a> BinWrite for OptionalStyle<'a> {
+impl<'a> BinWrite for ValueMod<'a> {
     type Args<'b> = ();
 
     fn write_options<W: Write + Seek>(
@@ -1162,7 +1286,7 @@ impl BinWrite for Value {
                 if number.var_name.is_some() || number.value_label.is_some() {
                     (
                         2u8,
-                        OptionalStyle::new(self),
+                        ValueMod::new(self),
                         SpvFormat {
                             format: number.format,
                             honor_small: number.honor_small,
@@ -1176,7 +1300,7 @@ impl BinWrite for Value {
                 } else {
                     (
                         1u8,
-                        OptionalStyle::new(self),
+                        ValueMod::new(self),
                         number.value.unwrap_or(-f64::MAX),
                         Show::as_spv(&number.show),
                     )
@@ -1186,7 +1310,7 @@ impl BinWrite for Value {
             ValueInner::String(string) => {
                 (
                     4u8,
-                    OptionalStyle::new(self),
+                    ValueMod::new(self),
                     SpvFormat {
                         format: if string.hex {
                             Format::new(Type::AHex, (string.s.len() * 2) as u16, 0).unwrap()
@@ -1205,7 +1329,7 @@ impl BinWrite for Value {
             ValueInner::Variable(variable) => {
                 (
                     5u8,
-                    OptionalStyle::new(self),
+                    ValueMod::new(self),
                     SpvString(&variable.var_name),
                     SpvString::optional(&variable.variable_label),
                     Show::as_spv(&variable.show),
@@ -1216,7 +1340,7 @@ impl BinWrite for Value {
                 (
                     3u8,
                     SpvString(&text.local),
-                    OptionalStyle::new(self),
+                    ValueMod::new(self),
                     SpvString(&text.id),
                     SpvString(&text.c),
                     SpvBool(true),
@@ -1226,7 +1350,7 @@ impl BinWrite for Value {
             ValueInner::Template(template) => {
                 (
                     0u8,
-                    OptionalStyle::new(self),
+                    ValueMod::new(self),
                     SpvString(&template.local),
                     template.args.len() as u32,
                 )
@@ -1249,7 +1373,7 @@ impl BinWrite for Value {
                 (
                     3u8,
                     SpvString(""),
-                    OptionalStyle::default(),
+                    ValueMod::default(),
                     SpvString(""),
                     SpvString(""),
                     SpvBool(true),