work on graphs rust
authorBen Pfaff <blp@cs.stanford.edu>
Mon, 12 Jan 2026 02:57:43 +0000 (18:57 -0800)
committerBen Pfaff <blp@cs.stanford.edu>
Mon, 12 Jan 2026 02:57:43 +0000 (18:57 -0800)
rust/pspp/src/spv/read.rs
rust/pspp/src/spv/read/graph.rs [new file with mode: 0644]
rust/pspp/src/spv/read/structure.rs

index 6a8c2c58dde50a76ca527bdc3fc40af71b1711af..cd8fca8d97bacd0cf0836fa660200190a27fb8fc 100644 (file)
@@ -31,6 +31,7 @@ use crate::{
     spv::{
         legacy_bin::LegacyBinWarning,
         read::{
+            graph::GraphWarning,
             legacy_xml::LegacyXmlWarning,
             light::LightWarning,
             structure::{OutlineItem, StructureMember},
@@ -39,6 +40,8 @@ use crate::{
 };
 
 mod css;
+#[allow(missing_docs)]
+pub mod graph;
 pub mod html;
 pub mod legacy_bin;
 #[allow(missing_docs)]
@@ -79,6 +82,9 @@ pub enum WarningDetails {
     /// {0}
     LegacyXmlWarning(LegacyXmlWarning),
 
+    /// {0}
+    GraphWarning(GraphWarning),
+
     /// Unknown page orientation {0:?}.
     UnknownOrientation(String),
 }
@@ -255,9 +261,6 @@ pub enum Error {
     /// Legacy table missing `graph` element.
     LegacyMissingGraph,
 
-    /// Graphs not yet implemented.
-    GraphTodo,
-
     /// Models not yet implemented.
     ModelTodo,
 
diff --git a/rust/pspp/src/spv/read/graph.rs b/rust/pspp/src/spv/read/graph.rs
new file mode 100644 (file)
index 0000000..5b26160
--- /dev/null
@@ -0,0 +1,1292 @@
+// PSPP - a program for statistical analysis.
+// Copyright (C) 2025 Free Software Foundation, Inc.
+//
+// This program is free software: you can redistribute it and/or modify it under
+// the terms of the GNU General Public License as published by the Free Software
+// Foundation, either version 3 of the License, or (at your option) any later
+// version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT
+// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+// FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
+// details.
+//
+// You should have received a copy of the GNU General Public License along with
+// this program.  If not, see <http://www.gnu.org/licenses/>.
+
+#![allow(dead_code)]
+use std::{
+    cell::{Cell, RefCell},
+    collections::{BTreeMap, HashMap},
+    fmt::Debug,
+    marker::PhantomData,
+    num::NonZeroUsize,
+};
+
+use displaydoc::Display;
+use enum_map::Enum;
+use indexmap::IndexMap;
+use ordered_float::OrderedFloat;
+use serde::Deserialize;
+
+use crate::{
+    data::Datum,
+    format::{F8_0, Type, UncheckedFormat},
+    output::pivot::{
+        CategoryLocator, Length, PivotTable,
+        look::{Color, VertAlign},
+    },
+};
+
+#[derive(Debug)]
+struct Ref<T> {
+    references: String,
+    _phantom: PhantomData<T>,
+}
+
+impl<T> Ref<T> {
+    fn get<'a>(&self, table: &HashMap<&str, &'a T>) -> Option<&'a T> {
+        table.get(self.references.as_str()).map(|v| &**v)
+    }
+}
+
+impl<'de, T> Deserialize<'de> for Ref<T> {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        Ok(Self {
+            references: String::deserialize(deserializer)?,
+            _phantom: PhantomData,
+        })
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+struct Map(HashMap<OrderedFloat<f64>, Datum<String>>);
+
+impl Map {
+    fn apply(&self, data: &mut Vec<Datum<String>>) {
+        for value in data {
+            if let Datum::Number(Some(number)) = value
+                && let Some(to) = self.0.get(&OrderedFloat(*number))
+            {
+                *value = to.clone();
+            }
+        }
+    }
+}
+
+/// A warning decoding a legacy XML member.
+#[derive(Clone, Debug, Display, thiserror::Error)]
+pub enum GraphWarning {
+    /// Table has no data.
+    MissingData,
+
+    /// Table has variables with unresolved dependencies: {0:?}.
+    UnresolvedDependencies(Vec<String>),
+
+    /// Derived variable {variable:?} has unsupported value expression {value:?}.
+    UnsupportedValue {
+        /// Name of derived variable.
+        variable: String,
+        /// Value expression.
+        value: String,
+    },
+
+    /// Unsupported applyToConverse.
+    UnsupportedApplyToConverse,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct Visualization {
+    // Locale used for output, e.g. `en-US`.
+    #[serde(rename = "@lang")]
+    _lang: String,
+
+    /// Base style for the graph.
+    #[serde(rename = "@style")]
+    _style: Ref<Style>,
+
+    #[serde(rename = "$value")]
+    children: Vec<VisChild>,
+}
+
+impl Visualization {
+    pub fn decode_series(
+        &self,
+        data: IndexMap<String, IndexMap<String, Vec<Datum<String>>>>,
+        warn: &mut dyn FnMut(GraphWarning),
+    ) -> BTreeMap<&str, Series> {
+        let mut variables: Vec<&dyn Variable> = self
+            .children
+            .iter()
+            .filter_map(|child| child.variable())
+            .collect();
+        let mut series = BTreeMap::<&str, Series>::new();
+        while !variables.is_empty() {
+            let n = variables.len();
+            variables.retain(|variable| !variable.decode(&data, &mut series, warn));
+            if n == variables.len() {
+                warn(GraphWarning::UnresolvedDependencies(
+                    variables
+                        .iter()
+                        .map(|variable| variable.name().into())
+                        .collect(),
+                ));
+                break;
+            }
+        }
+        series
+    }
+
+    fn graph(&self) -> Result<&Graph, super::Error> {
+        for child in &self.children {
+            match child {
+                VisChild::Graph(g) => return Ok(g),
+                _ => (),
+            }
+        }
+        Err(super::Error::LegacyMissingGraph)
+    }
+
+    pub fn decode(
+        &self,
+        binary_data: IndexMap<String, IndexMap<String, Vec<Datum<String>>>>,
+        warn: &mut dyn FnMut(GraphWarning),
+    ) -> Result<PivotTable, super::Error> {
+        dbg!(self);
+        let graph = self.graph()?;
+        let series = self.decode_series(binary_data, warn);
+        let styles = self
+            .children
+            .iter()
+            .filter_map(|child| child.style())
+            .filter_map(|style| style.id.as_ref().map(|id| (id.as_str(), style)))
+            .collect::<HashMap<_, _>>();
+
+        todo!()
+    }
+}
+
+pub struct Series {
+    pub name: String,
+    label: Option<String>,
+    categories: Vec<Option<usize>>,
+    pub values: Vec<Datum<String>>,
+    affixes: Vec<Affix>,
+    coordinate_to_index: RefCell<BTreeMap<usize, CategoryLocator>>,
+    dimension_index: Cell<Option<usize>>,
+}
+
+impl Debug for Series {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("Series")
+            .field("name", &self.name)
+            .finish_non_exhaustive()
+    }
+}
+
+impl Series {
+    fn new(name: String, categories: Vec<Option<usize>>, values: Vec<Datum<String>>) -> Self {
+        Self {
+            name,
+            label: None,
+            categories,
+            values,
+            affixes: Vec::new(),
+            coordinate_to_index: Default::default(),
+            dimension_index: Default::default(),
+        }
+    }
+    fn with_label(self, label: Option<String>) -> Self {
+        Self { label, ..self }
+    }
+    fn with_affixes(self, affixes: Vec<Affix>) -> Self {
+        Self { affixes, ..self }
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum VisChild {
+    SourceVariable(SourceVariable),
+    DerivedVariable(DerivedVariable),
+    CategoricalDomain(CategoricalDomain),
+    Graph(Graph),
+    LabelFrame(LabelFrame),
+    Container(Container),
+    Style(Style),
+    #[serde(other)]
+    Other,
+}
+
+impl VisChild {
+    fn variable(&self) -> Option<&dyn Variable> {
+        match self {
+            VisChild::SourceVariable(source_variable) => Some(source_variable),
+            VisChild::DerivedVariable(derived_variable) => Some(derived_variable),
+            _ => None,
+        }
+    }
+    fn style(&self) -> Option<&Style> {
+        match self {
+            Self::Style(style) => Some(style),
+            _ => None,
+        }
+    }
+}
+
+trait Variable {
+    fn decode<'a>(
+        &'a self,
+        data: &IndexMap<String, IndexMap<String, Vec<Datum<String>>>>,
+        series: &mut BTreeMap<&'a str, Series>,
+        warn: &mut dyn FnMut(GraphWarning),
+    ) -> bool;
+    fn name(&self) -> &str;
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct SourceVariable {
+    #[serde(rename = "@id")]
+    id: String,
+
+    /// The `source-name` in the `tableData.bin` member.
+    #[serde(rename = "@source")]
+    source: String,
+
+    /// The name of a variable within the source, corresponding to the
+    /// `variable-name` in the `tableData.bin` member.
+    #[serde(rename = "@sourceName")]
+    variable: String,
+
+    /// Variable label, if any.
+    #[serde(rename = "@label")]
+    label: Option<String>,
+
+    /// A variable whose string values correspond one-to-one with the values of
+    /// this variable and are suitable as value labels.
+    #[serde(rename = "@labelVariable")]
+    label_variable: Option<Ref<SourceVariable>>,
+
+    format: Option<Format>,
+    string_format: Option<StringFormat>,
+}
+
+impl SourceVariable {
+    fn affixes(&self) -> &[Affix] {
+        if let Some(format) = &self.format {
+            &format.affixes
+        } else if let Some(string_format) = &self.string_format {
+            &string_format.affixes
+        } else {
+            &[]
+        }
+    }
+}
+impl Variable for SourceVariable {
+    fn decode<'a>(
+        &'a self,
+        data: &IndexMap<String, IndexMap<String, Vec<Datum<String>>>>,
+        series: &mut BTreeMap<&'a str, Series>,
+        _warn: &mut dyn FnMut(GraphWarning),
+    ) -> bool {
+        let label_series = if let Some(label_variable) = &self.label_variable {
+            if let Some(label_series) = series.get(label_variable.references.as_str()) {
+                Some(label_series)
+            } else {
+                return false;
+            }
+        } else {
+            None
+        };
+
+        let (categories, mut data) = if let Some(source) = data.get(&self.source)
+            && let Some(values) = source.get(&self.variable)
+        {
+            let categories = values
+                .iter()
+                .map(|value| {
+                    value
+                        .as_number()
+                        .flatten()
+                        .and_then(|v| (v >= 0.0 && v < usize::MAX as f64).then_some(v as usize))
+                })
+                .collect();
+            (categories, values.clone())
+        } else {
+            (Vec::new(), Vec::new())
+        };
+        if let Some(format) = &self.format {
+            format.mapping().apply(&mut data);
+        } else if let Some(string_format) = &self.string_format {
+            string_format.mapping().apply(&mut data);
+        };
+        if let Some(label_series) = label_series {
+            let format = self.format.as_ref().map_or(F8_0, |f| f.decode());
+            data = label_series
+                .values
+                .iter()
+                .map(|label| {
+                    if label.is_number() {
+                        Datum::String(label.display(format).with_stretch().to_string())
+                    } else {
+                        label.clone()
+                    }
+                })
+                .collect();
+        }
+        series.insert(
+            &self.id,
+            Series::new(self.id.clone(), categories, data)
+                .with_affixes(Vec::from(self.affixes()))
+                .with_label(self.label.clone()),
+        );
+        true
+    }
+    fn name(&self) -> &str {
+        &self.variable
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct DerivedVariable {
+    #[serde(rename = "@id")]
+    id: String,
+
+    /// An expression that defines the variable's value.
+    #[serde(rename = "@value")]
+    value: String,
+    format: Option<Format>,
+    string_format: Option<StringFormat>,
+    #[serde(default, rename = "valueMapEntry")]
+    value_map: Vec<ValueMapEntry>,
+}
+
+impl DerivedVariable {
+    fn mapping(&self) -> Map {
+        Map(self
+            .value_map
+            .iter()
+            .flat_map(|vme| {
+                vme.from
+                    .split(';')
+                    .filter_map(|from| from.trim().parse::<f64>().ok())
+                    .map(|from| {
+                        (
+                            OrderedFloat(from),
+                            if let Ok(to) = vme.to.trim().parse::<f64>() {
+                                Datum::Number(Some(to))
+                            } else {
+                                Datum::String(vme.to.clone())
+                            },
+                        )
+                    })
+            })
+            .collect())
+    }
+}
+
+impl Variable for DerivedVariable {
+    fn decode<'a>(
+        &'a self,
+        _data: &IndexMap<String, IndexMap<String, Vec<Datum<String>>>>,
+        series: &mut BTreeMap<&'a str, Series>,
+        warn: &mut dyn FnMut(GraphWarning),
+    ) -> bool {
+        let mut values = if self.id == "column" || self.id == "row" {
+            vec![]
+        } else if let Some(rest) = self.id.strip_prefix("dimension")
+            && rest.parse::<usize>().is_ok()
+        {
+            vec![]
+        } else if self.value == "constant(0)" {
+            /// All the series have the same length, except for series with
+            /// length zero.  This returns the length of the first nonempty
+            /// series, or `None` if there isn't one yet.
+            fn series_len(series: &mut BTreeMap<&str, Series>) -> Option<usize> {
+                series.values().find_map(|series| {
+                    if !series.values.is_empty() {
+                        Some(series.values.len())
+                    } else {
+                        None
+                    }
+                })
+            }
+
+            if let Some(n_values) = series_len(series) {
+                (0..n_values).map(|_| Datum::Number(Some(0.0))).collect()
+            } else {
+                return false;
+            }
+        } else if self.value.starts_with("constant") {
+            vec![]
+        } else if let Some(rest) = self.value.strip_prefix("map(")
+            && let Some(var_name) = rest.strip_suffix(")")
+        {
+            let Some(dependency) = series.get(var_name) else {
+                return false;
+            };
+            dependency.values.clone()
+        } else {
+            warn(GraphWarning::UnsupportedValue {
+                variable: self.id.clone(),
+                value: self.value.clone(),
+            });
+            vec![]
+        };
+        self.mapping().apply(&mut values);
+        if let Some(format) = &self.format {
+            format.mapping().apply(&mut values);
+        } else if let Some(string_format) = &self.string_format {
+            string_format.mapping().apply(&mut values);
+        };
+        series.insert(
+            &self.id,
+            Series::new(
+                self.id.clone(),
+                (0..values.len()).map(|_| Some(0)).collect(),
+                values,
+            ),
+        );
+        true
+    }
+    fn name(&self) -> &str {
+        &self.id
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct CategoricalDomain {
+    #[serde(rename = "@id")]
+    id: Option<String>,
+
+    variable_reference: VariableReference,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct IntervalDomain {
+    range: IntervalRange,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct IntervalRange {
+    #[serde(rename = "@min")]
+    min: f64,
+
+    #[serde(rename = "@max")]
+    max: f64,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct VariableReference {
+    #[serde(rename = "@ref")]
+    reference: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct StringFormat {
+    #[serde(default, rename = "relabel")]
+    relabels: Vec<Relabel>,
+    #[serde(default, rename = "affix")]
+    affixes: Vec<Affix>,
+}
+
+impl StringFormat {
+    fn mapping(&self) -> Map {
+        Map(self
+            .relabels
+            .iter()
+            .map(|relabel| {
+                (
+                    OrderedFloat(relabel.from),
+                    Datum::String(relabel.to.clone()),
+                )
+            })
+            .collect())
+    }
+}
+
+#[derive(Deserialize, Debug, Default)]
+#[serde(rename_all = "camelCase")]
+struct Format {
+    #[serde(rename = "@baseFormat")]
+    base_format: Option<BaseFormat>,
+    #[serde(rename = "@mdyOrder")]
+    mdy_order: Option<MdyOrder>,
+    #[serde(rename = "@showQuarter")]
+    show_quarter: Option<bool>,
+    #[serde(rename = "@quarterPrefix")]
+    year_abbreviation: Option<bool>,
+    #[serde(rename = "@monthFormat")]
+    month_format: Option<MonthFormat>,
+    #[serde(rename = "@showWeek")]
+    show_week: Option<bool>,
+    #[serde(rename = "@showDay")]
+    show_day: Option<bool>,
+    #[serde(rename = "@showHour")]
+    show_hour: Option<bool>,
+    #[serde(rename = "@showSecond")]
+    show_second: Option<bool>,
+    #[serde(rename = "@showMillis")]
+    show_millis: Option<bool>,
+    #[serde(rename = "@maximumFractionDigits")]
+    maximum_fraction_digits: Option<i64>,
+    #[serde(rename = "@useGrouping")]
+    use_grouping: Option<bool>,
+    #[serde(rename = "@scientific")]
+    scientific: Option<Scientific>,
+    #[serde(default, rename = "@prefix")]
+    prefix: String,
+    #[serde(default, rename = "@suffix")]
+    suffix: String,
+    #[serde(rename = "@tryStringsAsNumbers", default)]
+    try_strings_as_numbers: bool,
+    #[serde(default, rename = "relabel")]
+    relabels: Vec<Relabel>,
+    #[serde(default, rename = "affix")]
+    affixes: Vec<Affix>,
+}
+
+impl Format {
+    fn mapping(&self) -> Map {
+        let format = self.decode();
+        Map(self
+            .relabels
+            .iter()
+            .map(|relabel| {
+                let value = match relabel.to.trim().parse::<f64>().ok() {
+                    Some(to) if self.try_strings_as_numbers => Datum::Number(Some(to)),
+                    Some(to) => Datum::String(
+                        Datum::<String>::Number(Some(to))
+                            .display(format)
+                            .with_stretch()
+                            .to_string(),
+                    ),
+                    None => Datum::String(relabel.to.clone()),
+                };
+                (OrderedFloat(relabel.from), value)
+            })
+            .collect())
+    }
+
+    fn decode(&self) -> crate::format::Format {
+        if self.base_format.is_some() {
+            SignificantDateTimeFormat::from(self).decode()
+        } else {
+            SignificantNumberFormat::from(self).decode()
+        }
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct NumberFormat {
+    #[serde(rename = "@maximumFractionDigits")]
+    maximum_fraction_digits: Option<i64>,
+    #[serde(rename = "@useGrouping")]
+    use_grouping: Option<bool>,
+    #[serde(rename = "@scientific")]
+    scientific: Option<Scientific>,
+    #[serde(default, rename = "@prefix")]
+    prefix: String,
+    #[serde(default, rename = "@suffix")]
+    suffix: String,
+    #[serde(default, rename = "affix")]
+    affixes: Vec<Affix>,
+}
+
+struct SignificantNumberFormat<'a> {
+    scientific: Option<Scientific>,
+    prefix: &'a str,
+    suffix: &'a str,
+    use_grouping: Option<bool>,
+    maximum_fraction_digits: Option<i64>,
+}
+
+impl<'a> From<&'a NumberFormat> for SignificantNumberFormat<'a> {
+    fn from(value: &'a NumberFormat) -> Self {
+        Self {
+            scientific: value.scientific,
+            prefix: &value.prefix,
+            suffix: &value.suffix,
+            use_grouping: value.use_grouping,
+            maximum_fraction_digits: value.maximum_fraction_digits,
+        }
+    }
+}
+
+impl<'a> From<&'a Format> for SignificantNumberFormat<'a> {
+    fn from(value: &'a Format) -> Self {
+        Self {
+            scientific: value.scientific,
+            prefix: &value.prefix,
+            suffix: &value.suffix,
+            use_grouping: value.use_grouping,
+            maximum_fraction_digits: value.maximum_fraction_digits,
+        }
+    }
+}
+
+impl<'a> SignificantNumberFormat<'a> {
+    fn decode(&self) -> crate::format::Format {
+        let type_ = if self.scientific == Some(Scientific::True) {
+            Type::E
+        } else if self.prefix == "$" {
+            Type::Dollar
+        } else if self.suffix == "%" {
+            Type::Pct
+        } else if self.use_grouping == Some(true) {
+            Type::Comma
+        } else {
+            Type::F
+        };
+        let d = match self.maximum_fraction_digits {
+            Some(d) if (0..=15).contains(&d) => d,
+            _ => 2,
+        };
+        UncheckedFormat {
+            type_,
+            w: 40,
+            d: d as u8,
+        }
+        .fix()
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct DateTimeFormat {
+    #[serde(rename = "@baseFormat")]
+    base_format: BaseFormat,
+    #[serde(rename = "@mdyOrder")]
+    mdy_order: Option<MdyOrder>,
+    #[serde(rename = "@showQuarter")]
+    show_quarter: Option<bool>,
+    #[serde(rename = "@yearAbbreviation")]
+    year_abbreviation: Option<bool>,
+    #[serde(rename = "@monthFormat")]
+    month_format: Option<MonthFormat>,
+    #[serde(rename = "@showWeek")]
+    show_week: Option<bool>,
+    #[serde(rename = "@showDay")]
+    show_day: Option<bool>,
+    #[serde(rename = "@showHour")]
+    show_hour: Option<bool>,
+    #[serde(rename = "@showSecond")]
+    show_second: Option<bool>,
+    #[serde(rename = "@showMillis")]
+    show_millis: Option<bool>,
+    #[serde(default, rename = "affix")]
+    affixes: Vec<Affix>,
+}
+
+struct SignificantDateTimeFormat {
+    base_format: Option<BaseFormat>,
+    show_quarter: Option<bool>,
+    show_week: Option<bool>,
+    show_day: Option<bool>,
+    show_hour: Option<bool>,
+    show_second: Option<bool>,
+    show_millis: Option<bool>,
+    mdy_order: Option<MdyOrder>,
+    month_format: Option<MonthFormat>,
+    year_abbreviation: Option<bool>,
+}
+
+impl From<&Format> for SignificantDateTimeFormat {
+    fn from(value: &Format) -> Self {
+        Self {
+            base_format: value.base_format,
+            show_quarter: value.show_quarter,
+            show_week: value.show_week,
+            show_day: value.show_day,
+            show_hour: value.show_hour,
+            show_second: value.show_second,
+            show_millis: value.show_millis,
+            mdy_order: value.mdy_order,
+            month_format: value.month_format,
+            year_abbreviation: value.year_abbreviation,
+        }
+    }
+}
+impl From<&DateTimeFormat> for SignificantDateTimeFormat {
+    fn from(value: &DateTimeFormat) -> Self {
+        Self {
+            base_format: Some(value.base_format),
+            show_quarter: value.show_quarter,
+            show_week: value.show_week,
+            show_day: value.show_day,
+            show_hour: value.show_hour,
+            show_second: value.show_second,
+            show_millis: value.show_millis,
+            mdy_order: value.mdy_order,
+            month_format: value.month_format,
+            year_abbreviation: value.year_abbreviation,
+        }
+    }
+}
+impl SignificantDateTimeFormat {
+    fn decode(&self) -> crate::format::Format {
+        let type_ = match self.base_format {
+            Some(BaseFormat::Date) => {
+                let type_ = if self.show_quarter == Some(true) {
+                    Type::QYr
+                } else if self.show_week == Some(true) {
+                    Type::WkYr
+                } else {
+                    match (self.mdy_order, self.month_format) {
+                        (Some(MdyOrder::DayMonthYear), Some(MonthFormat::Number)) => Type::EDate,
+                        (Some(MdyOrder::DayMonthYear), Some(MonthFormat::PaddedNumber)) => {
+                            Type::EDate
+                        }
+                        (Some(MdyOrder::DayMonthYear), _) => Type::Date,
+                        (Some(MdyOrder::YearMonthDay), _) => Type::SDate,
+                        _ => Type::ADate,
+                    }
+                };
+                let mut w = type_.min_width();
+                if self.year_abbreviation != Some(true) {
+                    w += 2;
+                };
+                return UncheckedFormat { type_, w, d: 0 }.try_into().unwrap();
+            }
+            Some(BaseFormat::DateTime) => {
+                if self.mdy_order == Some(MdyOrder::YearMonthDay) {
+                    Type::YmdHms
+                } else {
+                    Type::DateTime
+                }
+            }
+            _ => {
+                if self.show_day == Some(true) {
+                    Type::DTime
+                } else if self.show_hour == Some(true) {
+                    Type::Time
+                } else {
+                    Type::MTime
+                }
+            }
+        };
+        date_time_format(type_, self.show_second, self.show_millis)
+    }
+}
+
+fn date_time_format(
+    type_: Type,
+    show_second: Option<bool>,
+    show_millis: Option<bool>,
+) -> crate::format::Format {
+    let mut w = type_.min_width();
+    let mut d = 0;
+    if show_second == Some(true) {
+        w += 3;
+        if show_millis == Some(true) {
+            d = 3;
+            w += d as u16 + 1;
+        }
+    }
+    UncheckedFormat { type_, w, d }.try_into().unwrap()
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct ElapsedTimeFormat {
+    #[serde(rename = "@showDay")]
+    show_day: Option<bool>,
+    #[serde(rename = "@showHour")]
+    show_hour: Option<bool>,
+    #[serde(rename = "@showSecond")]
+    show_second: Option<bool>,
+    #[serde(rename = "@showMillis")]
+    show_millis: Option<bool>,
+    #[serde(default, rename = "affix")]
+    affixes: Vec<Affix>,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum BaseFormat {
+    Date,
+    Time,
+    DateTime,
+    ElapsedTime,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum MdyOrder {
+    DayMonthYear,
+    MonthDayYear,
+    YearMonthDay,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum MonthFormat {
+    Long,
+    Short,
+    Number,
+    PaddedNumber,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum Scientific {
+    OnlyForSmall,
+    WhenNeeded,
+    True,
+    False,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct Affix {
+    /// A reference to a footnote with the given 1-based index.
+    #[serde(rename = "@definesReference")]
+    defines_reference: NonZeroUsize,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Relabel {
+    #[serde(rename = "@from")]
+    from: f64,
+    #[serde(rename = "@to")]
+    to: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct ValueMapEntry {
+    #[serde(rename = "@from")]
+    from: String,
+    #[serde(rename = "@to")]
+    to: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Style {
+    #[serde(rename = "@id")]
+    id: Option<String>,
+
+    /// The text color or, in some cases, background color.
+    #[serde(rename = "@color")]
+    color: Option<Color>,
+
+    #[serde(rename = "@font-size")]
+    font_size: Option<String>,
+
+    #[serde(rename = "@font-weight")]
+    font_weight: Option<FontWeight>,
+
+    #[serde(rename = "@font-style")]
+    font_style: Option<FontStyle>,
+
+    #[serde(rename = "@font-underline")]
+    font_underline: Option<FontUnderline>,
+
+    #[serde(rename = "@textAlignment")]
+    text_alignment: Option<TextAlignment>,
+
+    #[serde(rename = "@labelLocationVertical")]
+    label_location_vertical: Option<LabelLocation>,
+
+    #[serde(rename = "@visible")]
+    visible: Option<bool>,
+
+    #[serde(rename = "@decimal-offset")]
+    decimal_offset: Option<Length>,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum FontWeight {
+    Regular,
+    Bold,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum FontStyle {
+    Regular,
+    Italic,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum FontUnderline {
+    None,
+    Underline,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum TextAlignment {
+    Left,
+    Right,
+    Center,
+    Decimal,
+    Mixed,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum LabelLocation {
+    Positive,
+    Negative,
+    Center,
+}
+
+impl From<LabelLocation> for VertAlign {
+    fn from(value: LabelLocation) -> Self {
+        match value {
+            LabelLocation::Positive => VertAlign::Top,
+            LabelLocation::Negative => VertAlign::Bottom,
+            LabelLocation::Center => VertAlign::Middle,
+        }
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Graph {
+    faceting: Option<Faceting>,
+    facet_layout: Option<FacetLayout>,
+    interval: Interval,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Faceting {
+    #[serde(default, rename = "$value")]
+    children: Vec<FacetingChild>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum FacetingChild {
+    Cross(Cross),
+    Layer(Layer),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Cross {
+    #[serde(rename = "$value")]
+    children: [CrossChild; 2],
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum CrossChild {
+    /// No dimensions along this axis.
+    Unity,
+    /// Dimensions along this axis.
+    Nest(Nest),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Nest {
+    #[serde(rename = "variableReference")]
+    variable_references: Vec<VariableReference>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Layer {
+    #[serde(rename = "@variable")]
+    variable: String,
+
+    #[serde(rename = "@value")]
+    value: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct FacetLayout {
+    #[serde(rename = "$value")]
+    children: Vec<FacetLayoutChild>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum FacetLayoutChild {
+    SetCellProperties(SetCellProperties),
+    FacetLevel(FacetLevel),
+    #[serde(other)]
+    Other,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct SetCellProperties {
+    #[serde(rename = "@applyToConverse", default)]
+    apply_to_converse: bool,
+
+    #[serde(rename = "$value")]
+    sets: Vec<Set>,
+
+    #[serde(rename = "union")]
+    union_: Option<Union>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Union {
+    #[serde(default, rename = "intersect")]
+    intersects: Vec<Intersect>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Intersect {
+    #[serde(default, rename = "$value")]
+    children: Vec<IntersectChild>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum IntersectChild {
+    Where(Where),
+    #[serde(other)]
+    Other,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Where {
+    #[serde(rename = "@variable")]
+    variable: String,
+    #[serde(rename = "@include")]
+    include: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum Set {
+    SetFormat(SetFormat),
+    SetStyle(SetStyle),
+    SetFrameStyle(SetFrameStyle),
+    #[serde(other)]
+    Other,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct SetStyle {
+    #[serde(rename = "@target")]
+    target: String,
+
+    #[serde(rename = "@style")]
+    style: Ref<Style>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct SetFrameStyle {
+    #[serde(rename = "@target")]
+    target: String,
+
+    #[serde(rename = "@style")]
+    style: Ref<Style>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct SetFormat {
+    #[serde(rename = "@target")]
+    target: String,
+
+    #[serde(rename = "$value")]
+    child: Option<SetFormatChild>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum SetFormatChild {
+    Format(Format),
+    NumberFormat(NumberFormat),
+    StringFormat(Vec<StringFormat>),
+    DateTimeFormat(DateTimeFormat),
+    ElapsedTimeFormat(ElapsedTimeFormat),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Interval {}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Labeling {
+    #[serde(rename = "$value", default)]
+    children: Vec<LabelingChild>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum LabelingChild {
+    Formatting(Formatting),
+    Footnotes(Footnotes),
+    #[serde(other)]
+    Other,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Formatting {
+    #[serde(rename = "@variable")]
+    variable: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Footnotes {
+    #[serde(rename = "@variable")]
+    variable: String,
+
+    #[serde(default, rename = "@superscript")]
+    superscript: bool,
+
+    #[serde(default, rename = "footnoteMapping")]
+    mappings: Vec<FootnoteMapping>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct FootnoteMapping {
+    #[serde(rename = "@definesReference")]
+    defines_reference: Option<NonZeroUsize>,
+
+    #[serde(rename = "@from")]
+    from: NonZeroUsize,
+
+    #[serde(rename = "@to")]
+    to: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct FacetLevel {
+    #[serde(rename = "@level")]
+    level: usize,
+
+    axis: Axis,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Axis {
+    label: Option<Label>,
+    major_ticks: MajorTicks,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct MajorTicks {
+    #[serde(rename = "@style")]
+    style: Ref<Style>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Label {
+    #[serde(rename = "@style")]
+    style: Ref<Style>,
+
+    #[serde(rename = "@textFrameStyle")]
+    text_frame_style: Option<Ref<Style>>,
+
+    #[serde(rename = "@purpose")]
+    purpose: Option<Purpose>,
+
+    #[serde(rename = "$value")]
+    child: LabelChild,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Enum)]
+#[serde(rename_all = "camelCase")]
+enum Purpose {
+    Title,
+    SubTitle,
+    SubSubTitle,
+    Layer,
+    Footnote,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum LabelChild {
+    Text(Vec<Text>),
+    #[serde(other)]
+    Other,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Text {
+    /// If this is present, then `text` defines the content or the marker for a
+    /// footnote.
+    #[serde(rename = "@usesReference")]
+    uses_reference: Option<NonZeroUsize>,
+
+    /// If this is present, then
+    #[serde(rename = "@definesReference")]
+    defines_reference: Option<NonZeroUsize>,
+
+    #[serde(default, rename = "$text")]
+    text: String,
+}
+
+enum DecodedText<'a> {
+    /// Defines footnote `index` content or marker as `text`.
+    FootnoteDefinition { index: usize, text: &'a str },
+
+    /// Adds a reference to footnote `index`.
+    FootnoteReference { index: usize },
+
+    /// Text content.
+    Text { text: &'a str },
+}
+
+impl Text {
+    fn decode(&self) -> DecodedText<'_> {
+        if let Some(uses_reference) = self.uses_reference {
+            DecodedText::FootnoteDefinition {
+                index: uses_reference.get() - 1,
+                text: &self.text,
+            }
+        } else if let Some(defines_reference) = self.defines_reference {
+            DecodedText::FootnoteReference {
+                index: defines_reference.get() - 1,
+            }
+        } else {
+            DecodedText::Text { text: &self.text }
+        }
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct LabelFrame {
+    label: Option<Label>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Container {
+    #[serde(rename = "labelFrame")]
+    #[serde(default)]
+    label_frames: Vec<LabelFrame>,
+}
index a2ad34871a4aad0eef5f5780eadb4ab04211ad7c..172e15775dc4666f5e66c49140688234d35859f7 100644 (file)
@@ -23,7 +23,9 @@ use crate::{
     spv::{
         Error,
         legacy_bin::LegacyBin,
-        read::{TableType, Warning, WarningDetails, legacy_xml::Visualization, light::LightTable},
+        read::{
+            TableType, Warning, WarningDetails, graph, legacy_xml::Visualization, light::LightTable,
+        },
     },
 };
 
@@ -98,7 +100,7 @@ impl OutlineItem {
                 let mut spv_info = container.spv_info().clone();
                 let result = match container.content {
                     Content::Table(table) => table.decode(archive, &mut *warn),
-                    Content::Graph(_) => Err(Error::GraphTodo),
+                    Content::Graph(graph) => graph.decode(archive, &mut *warn),
                     Content::Text(container_text) => Ok(container_text.into_item()),
                     Content::Image(image) => image.decode(archive),
                     Content::Model => Err(Error::ModelTodo),
@@ -393,6 +395,52 @@ impl Graph {
             csv: self.csv_member.clone(),
         }
     }
+
+    fn decode<R, F>(self, archive: &mut ZipArchive<R>, mut warn: F) -> Result<Item, Error>
+    where
+        R: Read + Seek,
+        F: FnMut(Warning),
+    {
+        let data = if let Some(bin_member_name) = &self.data_member {
+            let bin_member_name = bin_member_name.as_str();
+            let mut bin_member = archive.by_name(bin_member_name)?;
+            let mut bin_data = Vec::with_capacity(bin_member.size() as usize);
+            bin_member.read_to_end(&mut bin_data)?;
+            let mut cursor = Cursor::new(bin_data);
+            let legacy_bin = LegacyBin::read(&mut cursor).map_err(|e| {
+                e.with_message(format!(
+                    "While parsing {bin_member_name:?} as graph binary SPV member",
+                ))
+            })?;
+            legacy_bin.decode(&mut |w| {
+                warn(Warning {
+                    member: bin_member_name.into(),
+                    details: WarningDetails::LegacyBinWarning(w),
+                })
+            })
+        } else {
+            Default::default()
+        };
+
+        let xml_member_name = self.xml_member.as_str();
+        let member = BufReader::new(archive.by_name(xml_member_name)?);
+        let visualization: graph::Visualization = match serde_path_to_error::deserialize(
+            &mut quick_xml::de::Deserializer::from_reader(member),
+        )
+        .with_context(|| format!("Failed to parse {xml_member_name}"))
+        {
+            Ok(result) => result,
+            Err(error) => panic!("{error:?}"),
+        };
+        let pivot_table = visualization.decode(data, &mut |w| {
+            warn(Warning {
+                member: xml_member_name.into(),
+                details: WarningDetails::GraphWarning(w),
+            })
+        })?;
+
+        Ok(pivot_table.into_item())
+    }
 }
 
 mod raw {