From 95e59c7d7eac226ffc602f8688da7db8f84f61c8 Mon Sep 17 00:00:00 2001 From: Ben Pfaff Date: Fri, 31 Oct 2025 17:01:05 -0700 Subject: [PATCH] work --- rust/pspp/src/calendar.rs | 4 + rust/pspp/src/output/drivers/spv.rs | 4 +- rust/pspp/src/output/pivot.rs | 76 +++- rust/pspp/src/output/pivot/look_xml.rs | 507 ++++++++++++++++++++++--- rust/pspp/src/output/spv.rs | 14 +- rust/pspp/src/output/spv/legacy_bin.rs | 47 ++- rust/pspp/src/output/spv/legacy_xml.rs | 294 ++++++++------ rust/pspp/src/output/spv/light.rs | 23 +- 8 files changed, 770 insertions(+), 199 deletions(-) diff --git a/rust/pspp/src/calendar.rs b/rust/pspp/src/calendar.rs index 8da918863e..808a0d3bc0 100644 --- a/rust/pspp/src/calendar.rs +++ b/rust/pspp/src/calendar.rs @@ -27,6 +27,10 @@ pub fn date_time_to_pspp(date_time: NaiveDateTime) -> f64 { (date_time - EPOCH_DATETIME).as_seconds_f64() } +pub fn time_to_pspp(time: NaiveTime) -> f64 { + (time - NaiveTime::MIN).as_seconds_f64() +} + /// Takes a count of days from 14 Oct 1582 and translates it into a Gregorian /// calendar date, if possible. Positive and negative offsets are supported. pub fn calendar_offset_to_gregorian(offset: f64) -> Option { diff --git a/rust/pspp/src/output/drivers/spv.rs b/rust/pspp/src/output/drivers/spv.rs index 349657137b..db1a9e724b 100644 --- a/rust/pspp/src/output/drivers/spv.rs +++ b/rust/pspp/src/output/drivers/spv.rs @@ -806,8 +806,8 @@ impl BinWrite for Footnotes { endian: Endian, args: Self::Args<'_>, ) -> binrw::BinResult<()> { - (self.0.len() as u32).write_options(writer, endian, args)?; - for footnote in &self.0 { + (self.len() as u32).write_options(writer, endian, args)?; + for footnote in self { footnote.write_options(writer, endian, args)?; } Ok(()) diff --git a/rust/pspp/src/output/pivot.rs b/rust/pspp/src/output/pivot.rs index c2a5dc0b87..e0f5277d04 100644 --- a/rust/pspp/src/output/pivot.rs +++ b/rust/pspp/src/output/pivot.rs @@ -59,7 +59,7 @@ use color::{AlphaColor, Rgba8, Srgb, palette::css::TRANSPARENT}; use enum_iterator::Sequence; use enum_map::{Enum, EnumMap, enum_map}; use itertools::Itertools; -pub use look_xml::TableProperties; +pub use look_xml::{Length, TableProperties}; use quick_xml::{DeError, de::from_str}; use serde::{ Deserialize, Serialize, Serializer, @@ -640,7 +640,7 @@ where } #[derive(Clone, Debug, Default, Serialize)] -pub struct Footnotes(pub Vec>); +pub struct Footnotes(Vec>); impl Footnotes { pub fn new() -> Self { @@ -656,6 +656,32 @@ impl Footnotes { pub fn is_empty(&self) -> bool { self.0.is_empty() } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn get(&self, index: usize) -> Option<&Arc> { + self.0.get(index) + } +} + +impl Index for Footnotes { + type Output = Arc; + + fn index(&self, index: usize) -> &Self::Output { + &self.0[index] + } +} + +impl<'a> IntoIterator for &'a Footnotes { + type Item = &'a Arc; + + type IntoIter = std::slice::Iter<'a, Arc>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } } impl FromIterator for Footnotes { @@ -812,7 +838,7 @@ impl From<&String> for Category { /// The division between this and the style information in [PivotTable] seems /// fairly arbitrary. The ultimate reason for the division is simply because /// that's how SPSS documentation and file formats do it. -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] pub struct Look { pub name: Option, @@ -1025,7 +1051,7 @@ impl From for HeadingRegion { } } -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] pub struct AreaStyle { pub cell_style: CellStyle, pub font_style: FontStyle, @@ -1267,7 +1293,7 @@ impl Display for DisplayCss { } } -#[derive(Copy, Clone, Debug, Deserialize)] +#[derive(Copy, Clone, Debug, PartialEq, Deserialize)] pub struct BorderStyle { #[serde(rename = "@borderStyleType")] pub stroke: Stroke, @@ -2127,6 +2153,12 @@ impl Footnote { } } +impl Default for Footnote { + fn default() -> Self { + Footnote::new(Value::default()) + } +} + pub struct DisplayMarker<'a> { footnote: &'a Footnote, options: ValueOptions, @@ -2290,14 +2322,15 @@ impl Value { Datum::String(string) => Self::new_user_text(string.as_str()), } } - pub fn new_variable_value(variable: &Variable, value: &Datum) -> Self { - let var_name = Some(variable.name.as_str().into()); - let value_label = variable.value_labels.get(value).map(String::from); + pub fn new_datum_with_format(value: &Datum, format: Format) -> Self + where + B: EncodedString, + { match value { Datum::Number(number) => Self::new(ValueInner::Number(NumberValue { show: None, - format: match variable.print_format.var_type() { - VarType::Numeric => variable.print_format, + format: match format.var_type() { + VarType::Numeric => format, VarType::String => { #[cfg(debug_assertions)] panic!("cannot create numeric pivot value with string format"); @@ -2308,21 +2341,26 @@ impl Value { }, honor_small: false, value: *number, - variable: var_name, - value_label, + variable: None, + value_label: None, })), Datum::String(string) => Self::new(ValueInner::String(StringValue { show: None, - hex: variable.print_format.type_() == Type::AHex, - s: string - .as_ref() - .with_encoding(variable.encoding()) - .into_string(), - var_name, - value_label, + hex: format.type_() == Type::AHex, + s: string.as_str().into_owned(), + var_name: None, + value_label: None, })), } } + pub fn new_variable_value(variable: &Variable, value: &Datum) -> Self { + Self::new_datum_with_format( + &value.as_encoded(variable.encoding()), + variable.print_format, + ) + .with_variable_name(Some(variable.name.as_str().into())) + .with_value_label(variable.value_labels.get(value).map(String::from)) + } pub fn new_number(x: Option) -> Self { Self::new_number_with_format(x, F8_2) } diff --git a/rust/pspp/src/output/pivot/look_xml.rs b/rust/pspp/src/output/pivot/look_xml.rs index 2d338f6deb..908c322458 100644 --- a/rust/pspp/src/output/pivot/look_xml.rs +++ b/rust/pspp/src/output/pivot/look_xml.rs @@ -179,7 +179,7 @@ struct CellStyle { #[serde(rename = "@font-family")] font_family: String, #[serde(rename = "@font-size")] - font_size: Dimension, + font_size: Length, #[serde(rename = "@font-style")] font_style: FontStyle, #[serde(rename = "@font-weight")] @@ -189,17 +189,17 @@ struct CellStyle { #[serde(rename = "@labelLocationVertical")] label_location_vertical: LabelLocationVertical, #[serde(rename = "@margin-bottom")] - margin_bottom: Dimension, + margin_bottom: Length, #[serde(rename = "@margin-left")] - margin_left: Dimension, + margin_left: Length, #[serde(rename = "@margin-right")] - margin_right: Dimension, + margin_right: Length, #[serde(rename = "@margin-top")] - margin_top: Dimension, + margin_top: Length, #[serde(rename = "@textAlignment", default)] text_alignment: TextAlignment, #[serde(rename = "@decimal-offset")] - decimal_offset: Dimension, + decimal_offset: Length, } impl CellStyle { @@ -348,41 +348,41 @@ struct PrintingProperties { } #[derive(Copy, Clone, Default, PartialEq)] -pub struct Dimension( +pub struct Length( /// In inches. - f64, + pub f64, ); -impl Dimension { - fn as_px_f64(self) -> f64 { +impl Length { + pub fn as_px_f64(self) -> f64 { self.0 * 96.0 } - fn as_px_i32(self) -> i32 { + pub fn as_px_i32(self) -> i32 { num::cast(self.as_px_f64() + 0.5).unwrap_or_default() } - fn as_pt_f64(self) -> f64 { + pub fn as_pt_f64(self) -> f64 { self.0 * 72.0 } - fn as_pt_i32(self) -> i32 { + pub fn as_pt_i32(self) -> i32 { num::cast(self.as_pt_f64() + 0.5).unwrap_or_default() } } -impl Debug for Dimension { +impl Debug for Length { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{:.2}in", self.0) } } -impl FromStr for Dimension { - type Err = DimensionParseError; +impl FromStr for Length { + type Err = LengthParseError; fn from_str(s: &str) -> Result { let s = s.trim_start(); let unit = s.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.'); let number: f64 = s[..s.len() - unit.len()] .parse() - .map_err(DimensionParseError::ParseFloatError)?; + .map_err(LengthParseError::ParseFloatError)?; let divisor = match unit.trim() { // Inches. "in" | "인치" | "pol." | "cala" | "cali" => 1.0, @@ -396,14 +396,14 @@ impl FromStr for Dimension { // Centimeters. "cm" | "см" => 2.54, - other => return Err(DimensionParseError::InvalidUnit(other.into())), + other => return Err(LengthParseError::InvalidUnit(other.into())), }; - Ok(Dimension(number / divisor)) + Ok(Length(number / divisor)) } } #[derive(ThisError, Debug, PartialEq, Eq)] -enum DimensionParseError { +pub enum LengthParseError { /// Invalid number. #[error(transparent)] ParseFloatError(ParseFloatError), @@ -413,7 +413,7 @@ enum DimensionParseError { InvalidUnit(String), } -impl<'de> Deserialize<'de> for Dimension { +impl<'de> Deserialize<'de> for Length { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -421,13 +421,13 @@ impl<'de> Deserialize<'de> for Dimension { struct DimensionVisitor; impl<'de> Visitor<'de> for DimensionVisitor { - type Value = Dimension; + type Value = Length; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("a string") + formatter.write_str("a dimension expressed as a string, e.g. \"1.0 cm\"") } - fn visit_borrowed_str(self, v: &'de str) -> Result + fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { @@ -443,43 +443,49 @@ impl<'de> Deserialize<'de> for Dimension { mod tests { use std::str::FromStr; + use enum_map::{EnumMap, enum_map}; use quick_xml::de::from_str; - use crate::output::pivot::look_xml::{Dimension, DimensionParseError, TableProperties}; + use crate::output::pivot::{ + Area, AreaStyle, Axis2, Border, BorderStyle, BoxBorder, CellStyle, Color, FontStyle, + FootnoteMarkerPosition, FootnoteMarkerType, HeadingRegion, HorzAlign, LabelPosition, Look, + RowColBorder, RowParity, Stroke, VertAlign, + look_xml::{Length, LengthParseError, TableProperties}, + }; #[test] fn dimension() { - assert_eq!(Dimension::from_str("1"), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str("1pt"), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str("1пт"), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str("1.0"), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str(" 1.0"), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str(" 1.0 "), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str("1.0 pt"), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str("1.0pt "), Ok(Dimension(1.0 / 72.0))); - assert_eq!(Dimension::from_str(" 1.0pt "), Ok(Dimension(1.0 / 72.0))); + assert_eq!(Length::from_str("1"), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str("1pt"), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str("1пт"), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str("1.0"), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str(" 1.0"), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str(" 1.0 "), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str("1.0 pt"), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str("1.0pt "), Ok(Length(1.0 / 72.0))); + assert_eq!(Length::from_str(" 1.0pt "), Ok(Length(1.0 / 72.0))); - assert_eq!(Dimension::from_str("1in"), Ok(Dimension(1.0))); + assert_eq!(Length::from_str("1in"), Ok(Length(1.0))); - assert_eq!(Dimension::from_str("96px"), Ok(Dimension(1.0))); + assert_eq!(Length::from_str("96px"), Ok(Length(1.0))); - assert_eq!(Dimension::from_str("2.54cm"), Ok(Dimension(1.0))); + assert_eq!(Length::from_str("2.54cm"), Ok(Length(1.0))); assert_eq!( - Dimension::from_str(""), - Err(DimensionParseError::ParseFloatError( + Length::from_str(""), + Err(LengthParseError::ParseFloatError( "".parse::().unwrap_err() )) ); assert_eq!( - Dimension::from_str("1.2.3"), - Err(DimensionParseError::ParseFloatError( + Length::from_str("1.2.3"), + Err(LengthParseError::ParseFloatError( "1.2.3".parse::().unwrap_err() )) ); assert_eq!( - Dimension::from_str("1asdf"), - Err(DimensionParseError::InvalidUnit("asdf".into())) + Length::from_str("1asdf"), + Err(LengthParseError::InvalidUnit("asdf".into())) ); } @@ -541,7 +547,418 @@ mod tests { "##; let table_properties: TableProperties = from_str(XML).unwrap(); - dbg!(&table_properties); - todo!() + let look: Look = table_properties.into(); + dbg!(&look); + let expected = Look { + name: None, + hide_empty: true, + row_label_position: LabelPosition::Corner, + heading_widths: enum_map! { + HeadingRegion::Rows => 36..=120, + HeadingRegion::Columns => 36..=72, + }, + footnote_marker_type: FootnoteMarkerType::Alphabetic, + footnote_marker_position: FootnoteMarkerPosition::Subscript, + areas: enum_map! { + Area::Title => AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Left, + ), + vert_align: VertAlign::Middle, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 8, + ], + }, + }, + font_style: FontStyle { + bold: true, + italic: false, + underline: false, + markup: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Caption => AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Left, + ), + vert_align: VertAlign::Top, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 0, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + markup: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Footer => AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Left, + ), + vert_align: VertAlign::Top, + margins: enum_map! { + Axis2::X => [ + 11, + 8, + ], + Axis2::Y => [ + 1, + 3, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + markup: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Corner => AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Left, + ), + vert_align: VertAlign::Bottom, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 0, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + markup: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Labels( + Axis2::X, + ) => AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Center, + ), + vert_align: VertAlign::Bottom, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 3, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + markup: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Labels( + Axis2::Y, + )=> AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Left, + ), + vert_align: VertAlign::Top, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 3, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + markup: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Data( + RowParity::Even, + ) => AreaStyle { + cell_style: CellStyle { + horz_align: None, + vert_align: VertAlign::Top, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 0, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + markup: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + Area::Data( + RowParity::Odd, + )=>AreaStyle { + cell_style: CellStyle { + horz_align: None, + vert_align: VertAlign::Top, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 0, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + markup: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::BLACK, + size: 9, + }, + }, + Area::Layers => AreaStyle { + cell_style: CellStyle { + horz_align: Some( + HorzAlign::Left, + ), + vert_align: VertAlign::Bottom, + margins: enum_map! { + Axis2::X => [ + 8, + 11, + ], + Axis2::Y => [ + 0, + 3, + ], + }, + }, + font_style: FontStyle { + bold: false, + italic: false, + underline: false, + markup: false, + font: String::from("Sans Serif"), + fg: Color::BLACK, + bg: Color::WHITE, + size: 9, + }, + }, + }, + borders: enum_map! { + Border::Title => BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::OuterFrame( + BoxBorder::Left, + )=>BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::OuterFrame( + BoxBorder::Top, + ) =>BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::OuterFrame( + BoxBorder::Right, + ) => BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::OuterFrame( + BoxBorder::Bottom, + )=> BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::InnerFrame( + BoxBorder::Left, + )=> BorderStyle { + stroke: Stroke::Thick, + color: Color::BLACK, + }, + Border::InnerFrame( + BoxBorder::Top, + )=> BorderStyle { + stroke: Stroke::Thick, + color: Color::BLACK, + }, + Border::InnerFrame( + BoxBorder::Right, + )=> BorderStyle { + stroke: Stroke::Thick, + color: Color::BLACK, + }, + Border::InnerFrame( + BoxBorder::Bottom, + )=> BorderStyle { + stroke: Stroke::Thick, + color: Color::BLACK, + }, + Border::Dimension( + RowColBorder( + HeadingRegion::Rows, + Axis2::X, + ), + )=> BorderStyle { + stroke: Stroke::Solid, + color: Color::BLACK, + }, + Border::Dimension( + RowColBorder( + HeadingRegion::Columns, + Axis2::X, + ), + )=> BorderStyle { + stroke: Stroke::Solid, + color: Color::BLACK, + }, + Border::Dimension( + RowColBorder( + HeadingRegion::Rows, + Axis2::Y, + ), + )=> BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::Dimension( + RowColBorder( + HeadingRegion::Columns, + Axis2::Y, + ), + )=> BorderStyle { + stroke: Stroke::Solid, + color: Color::BLACK, + }, + Border::Category( + RowColBorder( + HeadingRegion::Rows, + Axis2::X, + ), + )=> BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::Category( + RowColBorder( + HeadingRegion::Columns, + Axis2::X, + ), + )=> BorderStyle { + stroke: Stroke::Solid, + color: Color::BLACK, + }, + Border::Category( + RowColBorder( + HeadingRegion::Rows, + Axis2::Y, + ), + )=> BorderStyle { + stroke: Stroke::None, + color: Color::BLACK, + }, + Border::Category( + RowColBorder( + HeadingRegion::Columns, + Axis2::Y, + ), + )=> BorderStyle { + stroke: Stroke::Solid, + color: Color::BLACK, + }, + Border::DataLeft => BorderStyle { + stroke: Stroke::Thick, + color: Color::BLACK, + }, + Border::DataTop => BorderStyle { + stroke: Stroke::Thick, + color: Color::BLACK, + }, + }, + print_all_layers: true, + paginate_layers: false, + shrink_to_fit: EnumMap::from_fn(|_| false), + top_continuation: false, + bottom_continuation: false, + continuation: None, + n_orphan_lines: 5, + }; + assert_eq!(&look, &expected); } } diff --git a/rust/pspp/src/output/spv.rs b/rust/pspp/src/output/spv.rs index 2b5acb0003..b47911c6d2 100644 --- a/rust/pspp/src/output/spv.rs +++ b/rust/pspp/src/output/spv.rs @@ -443,12 +443,14 @@ impl Table { Ok(result) => result, Err(error) => panic!("{error:?}"), }; - visualization.decode( - data, - self.properties - .as_ref() - .map_or_else(Look::default, |properties| properties.clone().into()), - ); + visualization + .decode( + data, + self.properties + .as_ref() + .map_or_else(Look::default, |properties| properties.clone().into()), + ) + .unwrap()/*XXX*/; Ok(PivotTable::new([]).into_item()) } diff --git a/rust/pspp/src/output/spv/legacy_bin.rs b/rust/pspp/src/output/spv/legacy_bin.rs index cc298eee7c..8af6c7f6fc 100644 --- a/rust/pspp/src/output/spv/legacy_bin.rs +++ b/rust/pspp/src/output/spv/legacy_bin.rs @@ -4,11 +4,17 @@ use std::{ }; use binrw::{BinRead, BinResult, binread}; +use chrono::{NaiveDateTime, NaiveTime}; use encoding_rs::UTF_8; use crate::{ + calendar::{date_time_to_pspp, time_to_pspp}, data::Datum, - output::spv::light::{U32String, parse_vec}, + format::{Category, Format}, + output::{ + pivot::Value, + spv::light::{U32String, decode_format, parse_vec}, + }, }; #[binread] @@ -76,6 +82,45 @@ pub struct DataValue { pub value: Datum, } +impl DataValue { + pub fn category(&self) -> Option { + match &self.value { + Datum::Number(number) => *number, + _ => self.index, + } + .and_then(|v| (v >= 0.0 && v < usize::MAX as f64).then_some(v as usize)) + } + + pub fn as_format(&self, format_map: &HashMap) -> Format { + let f = match &self.value { + Datum::Number(Some(number)) => *number as u32, + Datum::Number(None) => 0, + Datum::String(s) => s.parse().unwrap_or_default(), + }; + match format_map.get(&f) { + Some(format) => *format, + None => decode_format(f), + } + } + + pub fn as_pivot_value(&self, format: Format) -> Value { + if format.type_().category() == Category::Date + && let Some(s) = self.value.as_string() + && let Ok(date_time) = + NaiveDateTime::parse_from_str(s.as_str(), "%Y-%m-%dT%H:%M:%S%.3f") + { + Value::new_number_with_format(Some(date_time_to_pspp(date_time)), format) + } else if format.type_().category() == Category::Time + && let Some(s) = self.value.as_string() + && let Ok(time) = NaiveTime::parse_from_str(s.as_str(), "%H:%M:%S%.3f") + { + Value::new_number_with_format(Some(time_to_pspp(time)), format) + } else { + Value::new_datum_with_format(&self.value, format) + } + } +} + #[binread] #[br(little)] #[derive(Copy, Clone, Debug, PartialEq, Eq)] diff --git a/rust/pspp/src/output/spv/legacy_xml.rs b/rust/pspp/src/output/spv/legacy_xml.rs index f0ca5803b8..db02c0f5c7 100644 --- a/rust/pspp/src/output/spv/legacy_xml.rs +++ b/rust/pspp/src/output/spv/legacy_xml.rs @@ -18,26 +18,23 @@ use std::{ collections::{BTreeMap, HashMap}, marker::PhantomData, mem::take, - num::{NonZeroUsize, ParseFloatError}, - str::FromStr, + num::NonZeroUsize, }; use enum_map::{Enum, EnumMap}; -use itertools::Itertools; use ordered_float::OrderedFloat; -use serde::{Deserialize, de::Error as _}; +use serde::Deserialize; use crate::{ data::Datum, format::{Decimal::Dot, F8_0, Type, UncheckedFormat}, output::{ pivot::{ - self, Area, AreaStyle, Axis2, Axis3, Color, HeadingRegion, HorzAlign, Look, PivotTable, - RowParity, Value, VertAlign, + self, Area, AreaStyle, Axis2, Axis3, Color, HeadingRegion, HorzAlign, Leaf, Length, + Look, PivotTable, RowParity, Value, VertAlign, }, spv::legacy_bin::DataValue, }, - variable, }; #[derive(Debug)] @@ -192,7 +189,7 @@ impl Visualization { let mut graph = None; let mut labels = EnumMap::from_fn(|_| Vec::new()); let mut styles = HashMap::new(); - let mut layer_controller = None; + let mut _layer_controller = None; for child in &self.children { match child { VisChild::Extension(e) => extension = Some(e), @@ -224,11 +221,11 @@ impl Visualization { styles.insert(id.as_str(), style); } } - VisChild::LayerController(lc) => layer_controller = Some(lc), + VisChild::LayerController(lc) => _layer_controller = Some(lc), } } let Some(graph) = graph else { todo!() }; - let Some(user_source) = user_source else { + let Some(_user_source) = user_source else { todo!() }; @@ -241,37 +238,51 @@ impl Visualization { // Footnotes. // - // Any pivot_value might refer to footnotes, so it's important to - // process the footnotes early to ensure that those references can be - // resolved. There is a possible problem that a footnote might itself - // reference an as-yet-unprocessed footnote, but that's OK because - // footnote references don't actually look at the footnote contents but - // only resolve a pointer to where the footnote will go later. + // Any [Value] might refer to footnotes, so it's important to process + // the footnotes early to ensure that those references can be resolved. + // There is a possible problem that a footnote might itself reference an + // as-yet-unprocessed footnote, but that's OK because footnote + // references don't actually look at the footnote contents but only + // resolve a pointer to where the footnote will go later. // // Before we really start, create all the footnotes we'll fill in. This // is because sometimes footnotes refer to themselves or to each other // and we don't want to reject those references. - let mut footnotes = BTreeMap::::new(); + let mut footnote_builder = BTreeMap::::new(); if let Some(f) = &graph.interval.footnotes { - f.decode(&mut footnotes); + f.decode(&mut footnote_builder); } for child in &graph.interval.labeling.children { if let LabelingChild::Footnotes(f) = child { - f.decode(&mut footnotes); + f.decode(&mut footnote_builder); } } for label in &labels[Purpose::Footnote] { for (index, text) in label.text().iter().enumerate() { if let Some(uses_reference) = text.uses_reference { - let entry = footnotes.entry(uses_reference.get() - 1).or_default(); + let entry = footnote_builder + .entry(uses_reference.get() - 1) + .or_default(); if index % 2 == 0 { - entry.0 = text.text.strip_suffix('\n').unwrap_or(&text.text).into(); + entry.content = text.text.strip_suffix('\n').unwrap_or(&text.text).into(); } else { - entry.1 = text.text.strip_suffix('.').unwrap_or(&text.text).into(); + entry.marker = + Some(text.text.strip_suffix('.').unwrap_or(&text.text).into()); } } } } + let mut footnotes = Vec::new(); + for (index, footnote) in footnote_builder { + while footnotes.len() < index { + footnotes.push(pivot::Footnote::default()); + } + footnotes.push( + pivot::Footnote::new(footnote.content) + .with_marker(footnote.marker.map(|s| Value::new_user_text(s))), + ); + } + let footnotes = pivot::Footnotes::from_iter(footnotes); for (purpose, area) in [ (Purpose::Title, Area::Title), @@ -295,11 +306,11 @@ impl Visualization { look.areas[Area::Data(RowParity::Even)].clone(); } - let mut title = Value::empty(); - let mut caption = Value::empty(); + let mut _title = Value::empty(); + let mut _caption = Value::empty(); //Label::decode_ - let show_grid_lines = extension + let _show_grid_lines = extension .as_ref() .and_then(|extension| extension.show_gridline); if let Some(style) = styles.get(graph.cell_style.references.as_str()) @@ -309,11 +320,11 @@ impl Visualization { parts.next(); if let Some(min_width) = parts.next() && let Some(max_width) = parts.next() - && let Ok(min_width) = min_width.parse::() - && let Ok(max_width) = max_width.parse::() + && let Ok(min_width) = min_width.parse::() + && let Ok(max_width) = max_width.parse::() { look.heading_widths[HeadingRegion::Columns] = - min_width.as_pt() as usize..=max_width.as_pt() as usize; + min_width.as_pt_f64() as usize..=max_width.as_pt_f64() as usize; } } @@ -351,6 +362,9 @@ impl Visualization { styles: &HashMap<&str, &Style>, a: Axis3, look: &mut Look, + rotate_inner_column_labels: &mut bool, + rotate_outer_row_labels: &mut bool, + footnotes: &pivot::Footnotes, ) { let base_level = variables[0].1; if let Ok(a) = Axis2::try_from(a) @@ -367,7 +381,57 @@ impl Visualization { } if a == Axis3::Y && let Some(axis) = axes.get(&(base_level + variables.len() - 1)) - {} + { + Style::decode( + axis.major_ticks.style.get(&styles), + axis.major_ticks.tick_frame_style.get(&styles), + &mut look.areas[Area::Labels(Axis2::Y)], + ); + } + + if let Some(axis) = axes.get(&base_level) + && axis.major_ticks.label_angle == -90.0 + { + if a == Axis3::X { + *rotate_inner_column_labels = true; + } else { + *rotate_outer_row_labels = true; + } + } + + // Find the first row for each category. + let max_cat = variables[0].0.max_category().unwrap()/*XXX*/; + let mut cat_rows = vec![None; max_cat + 1]; + for (index, value) in variables[0].0.values.iter().enumerate() { + if let Some(row) = value.category() { + cat_rows[row].get_or_insert(index); + } + } + + // Drop missing categories and count what's left. + let cat_rows = cat_rows.into_iter().flatten().collect::>(); + + // Make leaf categories. + let mut cats = Vec::with_capacity(cat_rows.len()); + for row in cat_rows.iter().copied() { + let dv = &variables[0].0.values[row]; + let name = Value::new_datum(&dv.value); + let name = variables[0].0.add_affixes(name, &footnotes); + cats.push(Leaf::new(name)); + } + + // Now group them, in one pass per grouping variable, innermost first. + for j in 1..variables.len() { + // Find a sequence of categories `cat1...cat2`, that all have + // the same value in series `j`. (This might be only a single + // category.) */ + let series = variables[j].0; + let mut cat1 = 0; + while cat1 < cats.len() { + let mut cat2 = cat1 + 1; + while cat2 < cats.len() {} + } + } todo!() } @@ -379,6 +443,10 @@ impl Visualization { styles: &HashMap<&str, &Style>, a: Axis3, look: &mut Look, + rotate_inner_column_labels: &mut bool, + rotate_outer_row_labels: &mut bool, + footnotes: &pivot::Footnotes, + level_ofs: usize, ) -> Vec { let variables = variables @@ -396,22 +464,53 @@ impl Visualization { if let Some((var, level)) = var { dim_vars.push((var, level)); } else if !dim_vars.is_empty() { - decode_dimension(&dim_vars, axes, styles, a, look); + decode_dimension( + &dim_vars, + axes, + styles, + a, + look, + rotate_inner_column_labels, + rotate_outer_row_labels, + footnotes, + ); dim_vars.clear(); } } if !dim_vars.is_empty() { - decode_dimension(&dim_vars, axes, styles, a, look); + decode_dimension( + &dim_vars, + axes, + styles, + a, + look, + rotate_inner_column_labels, + rotate_outer_row_labels, + footnotes, + ); } todo!() } + let mut rotate_inner_column_labels = false; + let mut rotate_outer_row_labels = false; let cross = &graph.faceting.cross.children; let columns = cross .first() .map(|child| child.variables()) .unwrap_or_default(); - decode_dimensions(columns, &series, &axes, &styles, Axis3::X, &mut look, 1); + decode_dimensions( + columns, + &series, + &axes, + &styles, + Axis3::X, + &mut look, + &mut rotate_inner_column_labels, + &mut rotate_outer_row_labels, + &footnotes, + 1, + ); let rows = cross .get(1) .map(|child| child.variables()) @@ -423,6 +522,9 @@ impl Visualization { &styles, Axis3::Y, &mut look, + &mut rotate_inner_column_labels, + &mut rotate_outer_row_labels, + &footnotes, 1 + columns.len(), ); @@ -439,6 +541,27 @@ struct Series { affixes: Vec, } +impl Series { + fn add_affixes(&self, mut value: Value, footnotes: &pivot::Footnotes) -> Value { + for affix in &self.affixes { + if let Some(index) = affix.defines_reference.checked_sub(1) + && let Ok(index) = usize::try_from(index) + && let Some(footnote) = footnotes.get(index) + { + value = value.with_footnote(footnote); + } + } + value + } + + fn max_category(&self) -> Option { + self.values + .iter() + .filter_map(|value| value.category()) + .max() + } +} + #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] enum VisChild { @@ -1143,57 +1266,6 @@ struct ValueMapEntry { to: String, } -#[derive(Copy, Clone, Default, Debug, PartialEq, PartialOrd)] -struct Dimension(f64); - -impl Dimension { - fn as_px(&self) -> f64 { - self.0 * 96.0 - } - fn as_pt(&self) -> f64 { - self.0 * 72.0 - } -} - -impl<'de> Deserialize<'de> for Dimension { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let string = String::deserialize(deserializer)?; - Dimension::from_str(&string).map_err(D::Error::custom) - } -} - -impl FromStr for Dimension { - type Err = ParseFloatError; - - fn from_str(s: &str) -> Result { - fn parse_unit(s: &str) -> (f64, &str) { - for (unit, per_inch) in &[ - ("in", 1.0), - ("인치", 1.0), - ("pol.", 1.0), - ("cala", 1.0), - ("cali", 1.0), - ("cm", 2.54), - ("см", 2.54), - ("pt", 72.0), - ("пт", 72.0), - ("px", 96.0), - ] { - if let Some(rest) = s.strip_suffix(unit) { - return (*per_inch, rest); - } - } - (72.0, s) - } - - let (per_inch, s) = parse_unit(s); - Ok(Self(s.trim().parse::()? / per_inch)) - } -} - #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct Style { @@ -1253,16 +1325,16 @@ struct Style { font_underline: Option, #[serde(rename = "@margin-bottom")] - margin_bottom: Option, + margin_bottom: Option, #[serde(rename = "@margin-top")] - margin_top: Option, + margin_top: Option, #[serde(rename = "@margin-left")] - margin_left: Option, + margin_left: Option, #[serde(rename = "@margin-right")] - margin_right: Option, + margin_right: Option, #[serde(rename = "@textAlignment")] text_alignment: Option, @@ -1283,7 +1355,7 @@ struct Style { visible: Option, #[serde(rename = "@decimal-offset")] - decimal_offset: Option, + decimal_offset: Option, } impl Style { @@ -1386,13 +1458,13 @@ enum TextAlignment { } impl TextAlignment { - fn as_horz_align(&self, decimal_offset: Option) -> Option { + fn as_horz_align(&self, decimal_offset: Option) -> Option { match self { TextAlignment::Left => Some(HorzAlign::Left), TextAlignment::Right => Some(HorzAlign::Right), TextAlignment::Center => Some(HorzAlign::Center), TextAlignment::Decimal => Some(HorzAlign::Decimal { - offset: decimal_offset.unwrap_or_default().as_px(), + offset: decimal_offset.unwrap_or_default().as_px_f64(), decimal: Dot, }), TextAlignment::Mixed => None, @@ -1452,11 +1524,11 @@ struct Location { /// Minimum size. #[serde(rename = "@min")] - min: Option, + min: Option, /// Maximum size. #[serde(rename = "@max")] - max: Option, + max: Option, /// An element to attach to. Required when method is attach or same, not /// observed otherwise. @@ -1749,6 +1821,12 @@ struct FormatMapping { format: Option, } +#[derive(Clone, Debug, Default)] +struct Footnote { + content: String, + marker: Option, +} + #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] struct Footnotes { @@ -1763,9 +1841,11 @@ struct Footnotes { } impl Footnotes { - fn decode(&self, dst: &mut BTreeMap) { + fn decode(&self, dst: &mut BTreeMap) { for f in &self.mappings { - dst.entry(f.defines_reference.get() - 1).or_default().0 = f.to.clone(); + dst.entry(f.defines_reference.get() - 1) + .or_default() + .content = f.to.clone(); } } } @@ -1793,7 +1873,7 @@ struct FacetLevel { level: usize, #[serde(rename = "@gap")] - gap: Option, + gap: Option, axis: Axis, } @@ -1817,7 +1897,7 @@ struct MajorTicks { label_angle: f64, #[serde(rename = "@length")] - length: Dimension, + length: Length, #[serde(rename = "@style")] style: Ref