From 5f57d3eab6a4976f8c84171910b581a4360ac37b Mon Sep 17 00:00:00 2001 From: Ben Pfaff Date: Thu, 15 May 2025 16:55:45 -0700 Subject: [PATCH] table output is starting to work --- rust/doc/src/spv/index.md | 4 +- rust/doc/src/spv/light-detail.md | 9 +- rust/pspp/src/output/mod.rs | 7 + rust/pspp/src/output/pivot/test.rs | 17 ++- rust/pspp/src/output/spv.rs | 226 ++++++++++++++++++++++------- 5 files changed, 200 insertions(+), 63 deletions(-) diff --git a/rust/doc/src/spv/index.md b/rust/doc/src/spv/index.md index 28f985221e..158e40e879 100644 --- a/rust/doc/src/spv/index.md +++ b/rust/doc/src/spv/index.md @@ -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 diff --git a/rust/doc/src/spv/light-detail.md b/rust/doc/src/spv/light-detail.md index 6b70c7be58..16f1ae8f27 100644 --- a/rust/doc/src/spv/light-detail.md +++ b/rust/doc/src/spv/light-detail.md @@ -1,6 +1,11 @@ # Light Detail Member Format This section describes the format of "light" detail `.bin` members. + + + +## 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. - - - ## Header An SPV light member begins with a 39-byte header: diff --git a/rust/pspp/src/output/mod.rs b/rust/pspp/src/output/mod.rs index ea3b4274ee..6eadd671e7 100644 --- a/rust/pspp/src/output/mod.rs +++ b/rust/pspp/src/output/mod.rs @@ -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 { diff --git a/rust/pspp/src/output/pivot/test.rs b/rust/pspp/src/output/pivot/test.rs index eab8653300..1354a66d6b 100644 --- a/rust/pspp/src/output/pivot/test.rs +++ b/rust/pspp/src/output/pivot/test.rs @@ -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); } } diff --git a/rust/pspp/src/output/spv.rs b/rust/pspp/src/output/spv.rs index df39a7126c..c3293f2479 100644 --- a/rust/pspp/src/output/spv.rs +++ b/rust/pspp/src/output/spv.rs @@ -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 +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 where W: Write + Seek, { writer: ZipWriter, needs_page_break: bool, next_table_id: u64, + next_heading_id: u64, } -impl SpvWriter +impl SpvDriver 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( + &mut self, + item: &Item, + pivot_table: &PivotTable, + structure: &mut XmlWriter, + ) 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 { + fn write_item(&mut self, item: &Item, structure: &mut XmlWriter) + 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::>(); + let mut attributes = Vec::::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, + item: &Item, + inner_elem: &str, + closure: F, + ) where + X: Write, + F: FnOnce(ElementWriter), + { + 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 Driver for SpvWriter +impl Driver for SpvDriver where W: Write + Seek, { @@ -396,7 +473,54 @@ where } fn write(&mut self, item: &Arc) { - 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>, 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( @@ -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), -- 2.30.2