work on xml spvs
authorBen Pfaff <blp@cs.stanford.edu>
Wed, 12 Nov 2025 16:05:08 +0000 (08:05 -0800)
committerBen Pfaff <blp@cs.stanford.edu>
Wed, 12 Nov 2025 16:05:08 +0000 (08:05 -0800)
rust/pspp/src/output/pivot.rs
rust/pspp/src/output/spv.rs
rust/pspp/src/output/spv/html.rs
rust/pspp/src/output/spv/legacy_bin.rs
rust/pspp/src/output/spv/legacy_xml.rs

index 197835b05d060183cba2e912ffdb8f7d2e9a13a2..badb4f8ea9b8646651547f94151e311f9129fe21 100644 (file)
@@ -495,6 +495,8 @@ pub struct Path<'a> {
     leaf: &'a Leaf,
 }
 
+pub type IndexVec = SmallVec<[usize; 4]>;
+
 impl Dimension {
     pub fn new(root: Group) -> Self {
         Dimension {
@@ -521,6 +523,10 @@ impl Dimension {
         self.root.leaf_path(index, SmallVec::new())
     }
 
+    pub fn index_path(&self, index: usize) -> Option<IndexVec> {
+        self.root.index_path(index, SmallVec::new())
+    }
+
     pub fn with_all_labels_hidden(self) -> Self {
         Self {
             hide_all_labels: true,
@@ -529,6 +535,37 @@ impl Dimension {
     }
 }
 
+/// Specifies a [Category] within a [Group].
+#[derive(Copy, Clone, Debug)]
+pub struct CategoryLocator {
+    /// The index of the leaf to start from.
+    pub leaf_index: usize,
+
+    /// The number of times to go up a level from the leaf.  If this category is
+    /// a leaf, this is 0, otherwise it is positive.
+    pub level: usize,
+}
+
+impl CategoryLocator {
+    pub fn new_leaf(leaf_index: usize) -> Self {
+        Self {
+            leaf_index,
+            level: 0,
+        }
+    }
+
+    pub fn parent(&self) -> Self {
+        Self {
+            leaf_index: self.leaf_index,
+            level: self.level + 1,
+        }
+    }
+
+    pub fn as_leaf(&self) -> Option<usize> {
+        (self.level == 0).then_some(self.leaf_index)
+    }
+}
+
 #[derive(Clone, Debug, Serialize)]
 pub struct Group {
     #[serde(skip)]
@@ -561,7 +598,7 @@ impl Group {
 
     pub fn push(&mut self, child: impl Into<Category>) {
         let mut child = child.into();
-        if let Category::Group(group) = &mut child {
+        if let Some(group) = child.as_group_mut() {
             group.show_label = true;
         }
         self.len += child.len();
@@ -613,6 +650,45 @@ impl Group {
         None
     }
 
+    fn index_path(&self, mut index: usize, mut path: IndexVec) -> Option<IndexVec> {
+        for (i, child) in self.children.iter().enumerate() {
+            let len = child.len();
+            if index < len {
+                path.push(i);
+                return child.index_path(index, path);
+            }
+            index -= len;
+        }
+        None
+    }
+
+    fn locator_path(&self, locator: CategoryLocator) -> Option<IndexVec> {
+        let mut path = self.index_path(locator.leaf_index, IndexVec::new())?;
+        path.truncate(path.len().checked_sub(locator.level)?);
+        Some(path)
+    }
+
+    /// Returns `None` if `locator` is invalid (that is, if `locator.leaf_idx >=
+    /// self.len` or `locator.level` is greater than the depth of the leaf) or
+    /// if `locator` designates `self`.
+    pub fn category(&self, locator: CategoryLocator) -> Option<&Category> {
+        let path = self.locator_path(locator)?;
+        let mut this = &self.children[*path.get(0)?];
+        for index in path[1..].iter().copied() {
+            this = &this.as_group().unwrap().children[index];
+        }
+        Some(this)
+    }
+
+    pub fn category_mut(&mut self, locator: CategoryLocator) -> Option<&mut Category> {
+        let path = self.locator_path(locator)?;
+        let mut this = &mut self.children[*path.get(0)?];
+        for index in path[1..].iter().copied() {
+            this = &mut this.as_group_mut().unwrap().children[index];
+        }
+        Some(this)
+    }
+
     pub fn len(&self) -> usize {
         self.len
     }
@@ -731,7 +807,7 @@ pub enum Class {
     Count,
 }
 
-/// A pivot_category is a leaf (a category) or a group.
+/// A leaf category or a group of them.
 #[derive(Clone, Debug, Serialize)]
 pub enum Category {
     Group(Group),
@@ -739,6 +815,34 @@ pub enum Category {
 }
 
 impl Category {
+    pub fn as_group(&self) -> Option<&Group> {
+        match self {
+            Category::Group(group) => Some(group),
+            Category::Leaf(leaf) => None,
+        }
+    }
+
+    pub fn as_group_mut(&mut self) -> Option<&mut Group> {
+        match self {
+            Category::Group(group) => Some(group),
+            Category::Leaf(leaf) => None,
+        }
+    }
+
+    pub fn as_leaf(&self) -> Option<&Leaf> {
+        match self {
+            Category::Group(group) => None,
+            Category::Leaf(leaf) => Some(leaf),
+        }
+    }
+
+    pub fn as_leaf_mut(&mut self) -> Option<&mut Leaf> {
+        match self {
+            Category::Group(group) => None,
+            Category::Leaf(leaf) => Some(leaf),
+        }
+    }
+
     pub fn name(&self) -> &Value {
         match self {
             Category::Group(group) => &group.name,
@@ -746,6 +850,13 @@ impl Category {
         }
     }
 
+    pub fn name_mut(&mut self) -> &mut Value {
+        match self {
+            Category::Group(group) => &mut group.name,
+            Category::Leaf(leaf) => &mut leaf.0,
+        }
+    }
+
     pub fn is_empty(&self) -> bool {
         self.len() == 0
     }
@@ -760,27 +871,49 @@ impl Category {
     pub fn nth_leaf(&self, index: usize) -> Option<&Leaf> {
         match self {
             Category::Group(group) => group.nth_leaf(index),
-            Category::Leaf(leaf) => {
-                if index == 0 {
-                    Some(leaf)
-                } else {
-                    None
-                }
-            }
+            Category::Leaf(leaf) if index == 0 => Some(leaf),
+            _ => None,
         }
     }
 
     pub fn leaf_path<'a>(&'a self, index: usize, groups: GroupVec<'a>) -> Option<Path<'a>> {
         match self {
             Category::Group(group) => group.leaf_path(index, groups),
-            Category::Leaf(leaf) => {
-                if index == 0 {
-                    Some(Path { groups, leaf })
-                } else {
-                    None
-                }
-            }
+            Category::Leaf(leaf) if index == 0 => Some(Path { groups, leaf }),
+            _ => None,
+        }
+    }
+
+    fn index_path(&self, index: usize, mut path: IndexVec) -> Option<IndexVec> {
+        match self {
+            Category::Group(group) => group.index_path(index, path),
+            Category::Leaf(leaf) if index == 0 => Some(path),
+            _ => None,
+        }
+    }
+
+    fn locator_path(&self, locator: CategoryLocator) -> Option<IndexVec> {
+        let mut path = self.index_path(locator.leaf_index, IndexVec::new())?;
+        path.truncate(path.len().checked_sub(locator.level)?);
+        Some(path)
+    }
+
+    /// Returns `None` if `locator` is invalid (that is, if `locator.leaf_idx >=
+    /// self.len` or `locator.level` is greater than the depth of the leaf).
+    pub fn category(&self, locator: CategoryLocator) -> Option<&Category> {
+        let mut this = self;
+        for index in this.locator_path(locator)? {
+            this = &this.as_group().unwrap().children[index];
+        }
+        Some(this)
+    }
+
+    pub fn category_mut(&mut self, locator: CategoryLocator) -> Option<&mut Category> {
+        let mut this = self;
+        for index in this.locator_path(locator)? {
+            this = &mut this.as_group_mut().unwrap().children[index];
         }
+        Some(this)
     }
 
     pub fn show_label(&self) -> bool {
@@ -1878,30 +2011,15 @@ pub trait CellIndex {
         I: ExactSizeIterator<Item = usize>;
 }
 
-impl<const N: usize> CellIndex for &[usize; N] {
-    fn cell_index<I>(self, dimensions: I) -> usize
-    where
-        I: ExactSizeIterator<Item = usize>,
-    {
-        self.as_slice().cell_index(dimensions)
-    }
-}
-
-impl<const N: usize> CellIndex for [usize; N] {
-    fn cell_index<I>(self, dimensions: I) -> usize
-    where
-        I: ExactSizeIterator<Item = usize>,
-    {
-        self.as_slice().cell_index(dimensions)
-    }
-}
-
-impl CellIndex for &[usize] {
+impl<T> CellIndex for T
+where
+    T: AsRef<[usize]>,
+{
     fn cell_index<I>(self, dimensions: I) -> usize
     where
         I: ExactSizeIterator<Item = usize>,
     {
-        let data_indexes = self;
+        let data_indexes = self.as_ref();
         let mut index = 0;
         for (dimension, data_index) in dimensions.zip_eq(data_indexes.iter()) {
             debug_assert!(*data_index < dimension);
@@ -2762,15 +2880,9 @@ impl Display for DisplayValue<'_> {
             ValueInner::Text(TextValue {
                 localized: local, ..
             }) => {
-                /*
-                if self
-                    .inner
-                    .styling
-                    .as_ref()
-                    .is_some_and(|styling| styling.style.font_style.markup)
-                {
-                    todo!();
-                }*/
+                if self.markup {
+                    dbg!(local);
+                }
                 f.write_str(local)
             }
 
index f42c478dacd5f3178afaf47a60c369e9874f2d05..f2b22359d580a8d0f007290dccf8c61c054c0273 100644 (file)
@@ -481,6 +481,7 @@ struct ContainerText {
 
 impl ContainerText {
     fn decode(&self) -> Value {
+        dbg!(&self.html);
         html::parse(&self.html)
     }
 }
index 06bc616ac7d108b772c0846617b55fe12cc3e941..ff9deb24674230099239d2f02e35786a1b0c1364 100644 (file)
@@ -24,7 +24,35 @@ fn find_element<'a>(elements: &'a [Node], name: &str) -> Option<&'a Element> {
 
 fn get_node_text(node: &Node, text: &mut String) {
     match node {
-        Node::Text(string) => text.push_str(&string),
+        Node::Text(string) => {
+            let mut s = string.as_str();
+            'OUTER: while !s.is_empty() {
+                let amp = s.find('&').unwrap_or(s.len());
+                let (head, rest) = s.split_at(amp);
+                text.push_str(head);
+                if rest.is_empty() {
+                    break;
+                }
+
+                static ENTITIES: [(&str, char); 6] = [
+                    ("&amp;", '&'),
+                    ("&lt;", '<'),
+                    ("&gt;", '>'),
+                    ("&apos;", '\''),
+                    ("&quot;", '"'),
+                    ("&nbsp;", '\u{00a0}'),
+                ];
+                for (name, character) in ENTITIES {
+                    if let Some(rest) = rest.strip_prefix(name) {
+                        text.push(character);
+                        s = rest;
+                        continue 'OUTER;
+                    }
+                }
+                text.push('&');
+                s = &s[1..];
+            }
+        }
         Node::Element(element) => get_element_text(element, text),
         Node::Comment(_) => (),
     }
@@ -39,6 +67,7 @@ fn get_element_text(element: &Element, text: &mut String) {
 fn extract_html_text(node: &Node, base_font_size: i32, s: &mut String) {
     match node {
         Node::Text(text) => {
+            dbg!(text);
             for c in text.chars() {
                 fn push_whitespace(c: char, s: &mut String) {
                     if s.chars().next_back().is_none_or(|c| !c.is_whitespace()) {
@@ -62,7 +91,6 @@ fn extract_html_text(node: &Node, base_font_size: i32, s: &mut String) {
                     _ if c.is_whitespace() => push_whitespace(c, s),
                     '<' => s.push_str("&lt;"),
                     '>' => s.push_str("&gt;"),
-                    '&' => s.push_str("&amp;"),
                     _ => s.push(c),
                 }
             }
@@ -350,6 +378,7 @@ pub fn parse(input: &str) -> Value {
                     extract_html_text(node, font_style.size, &mut s);
                 }
             }
+            dbg!(&s);
             s
         }
         _ => input.into(),
@@ -359,6 +388,8 @@ pub fn parse(input: &str) -> Value {
 
 #[cfg(test)]
 mod tests {
+    use html_parser::Dom;
+
     use crate::output::{
         pivot::{FontStyle, Value},
         spv::html::{parse, parse_paragraphs, parse_value},
@@ -396,6 +427,13 @@ mod tests {
         );
     }
 
+    #[test]
+    fn entity() {
+        let html = r#"<!doctype html><html><body>Hi&nbsp;there!</body></html>"#;
+        dbg!(Dom::parse(html));
+        todo!()
+    }
+
     #[test]
     fn paragraphs() {
         let paragraphs = parse_paragraphs(
index dd9a2ccb265e1b80e57a15e0a65d66fb60320e6a..a96ca10e66718fb4307bb26b270e19afe4b05183 100644 (file)
@@ -105,9 +105,8 @@ impl DataValue {
     }
 
     pub fn as_pivot_value(&self, format: Format) -> Value {
-        dbg!(format.type_());
         if format.type_().category() == Category::Date
-            && let Some(s) = self.value.as_string()
+            && let Some(s) = dbg!(self.value.as_string())
             && let Ok(date_time) =
                 NaiveDateTime::parse_from_str(s.as_str(), "%Y-%m-%dT%H:%M:%S%.3f")
         {
index 2bfec0fbc3da1f35376e690535c05f156534e784..08509304f5567a609dae866a481bd05fd3c2611e 100644 (file)
 // this program.  If not, see <http://www.gnu.org/licenses/>.
 
 use std::{
+    cell::{Cell, RefCell},
     collections::{BTreeMap, HashMap},
     marker::PhantomData,
     mem::take,
     num::NonZeroUsize,
+    ops::Range,
+    sync::Arc,
 };
 
+use chrono::{NaiveDateTime, NaiveTime};
 use enum_map::{Enum, EnumMap};
+use hashbrown::HashSet;
+use itertools::Itertools;
 use ordered_float::OrderedFloat;
 use serde::Deserialize;
 
 use crate::{
+    calendar::{date_time_to_pspp, time_to_pspp},
     data::Datum,
-    format::{Decimal::Dot, F8_0, F40_2, Type, UncheckedFormat},
+    format::{self, Decimal::Dot, F8_0, F40_2, Type, UncheckedFormat},
     output::{
         pivot::{
-            self, Area, AreaStyle, Axis2, Axis3, Category, Color, Dimension, Group, HeadingRegion,
-            HorzAlign, Leaf, Length, Look, NumberValue, PivotTable, RowParity, Value, ValueInner,
-            VertAlign,
+            self, Area, AreaStyle, Axis2, Axis3, Category, CategoryLocator, CellStyle, Color,
+            Dimension, Group, HeadingRegion, HorzAlign, Leaf, Length, Look, NumberValue,
+            PivotTable, RowParity, Value, ValueInner, VertAlign,
         },
         spv::legacy_bin::DataValue,
+        table,
     },
 };
 
@@ -241,9 +249,14 @@ impl Visualization {
         };
 
         let mut axes = HashMap::new();
+        let mut major_ticks = HashMap::new();
         for child in &graph.facet_layout.children {
             if let FacetLayoutChild::FacetLevel(facet_level) = child {
                 axes.insert(facet_level.level, &facet_level.axis);
+                major_ticks.insert(
+                    facet_level.axis.major_ticks.id.as_str(),
+                    &facet_level.axis.major_ticks,
+                );
             }
         }
 
@@ -305,10 +318,12 @@ impl Visualization {
                 label.decode_style(&mut look.areas[area], &styles);
             }
         }
+        let title = LabelFrame::decode_label(&labels[Purpose::Title]);
+        let caption = LabelFrame::decode_label(&labels[Purpose::SubTitle]);
         if let Some(style) = &graph.interval.labeling.style
             && let Some(style) = styles.get(style.references.as_str())
         {
-            Style::decode(
+            Style::decode_area(
                 Some(*style),
                 graph.cell_style.get(&styles),
                 &mut look.areas[Area::Data(RowParity::Even)],
@@ -317,10 +332,6 @@ impl Visualization {
                 look.areas[Area::Data(RowParity::Even)].clone();
         }
 
-        let mut _title = Value::empty();
-        let mut _caption = Value::empty();
-        //Label::decode_
-
         let _show_grid_lines = extension
             .as_ref()
             .and_then(|extension| extension.show_gridline);
@@ -386,7 +397,7 @@ impl Visualization {
                 let out = &mut look.areas[Area::Labels(a)];
                 *out = Area::Labels(a).default_area_style();
                 let style = label.style.get(&styles);
-                Style::decode(
+                Style::decode_area(
                     style,
                     label.text_frame_style.as_ref().and_then(|r| r.get(styles)),
                     out,
@@ -398,7 +409,7 @@ impl Visualization {
             if a == Axis3::Y
                 && let Some(axis) = axes.get(&(base_level + variables.len() - 1))
             {
-                Style::decode(
+                Style::decode_area(
                     axis.major_ticks.style.get(&styles),
                     axis.major_ticks.tick_frame_style.get(&styles),
                     &mut look.areas[Area::Labels(Axis2::Y)],
@@ -420,6 +431,21 @@ impl Visualization {
                 .map(|(series, _level)| *series)
                 .collect::<Vec<_>>();
 
+            #[derive(Clone)]
+            struct CatBuilder {
+                /// The category we've built so far.
+                category: Category,
+
+                /// The range of leaf indexes covered by `category`.
+                ///
+                /// If `category` is a leaf, the range has a length of 1.
+                /// If `category` is a group, the length is at least 1.
+                leaves: Range<usize>,
+
+                /// How to find this category in its dimension.
+                location: CategoryLocator,
+            }
+
             // Make leaf categories.
             let mut coordinate_to_index = HashMap::new();
             let mut cats = Vec::new();
@@ -427,40 +453,51 @@ impl Visualization {
                 let Some(row) = value.category() else {
                     continue;
                 };
-                coordinate_to_index.insert(row, index);
+                coordinate_to_index.insert(row, CategoryLocator::new_leaf(index));
                 let name = variables[0].new_name(value, footnotes);
-                cats.push((Category::from(Leaf::new(name)), cats.len()..cats.len() + 1));
+                cats.push(CatBuilder {
+                    category: Category::from(Leaf::new(name)),
+                    leaves: cats.len()..cats.len() + 1,
+                    location: CategoryLocator::new_leaf(cats.len()),
+                });
             }
+            *variables[0].coordinate_to_index.borrow_mut() = coordinate_to_index;
 
             // Now group them, in one pass per grouping variable, innermost first.
             for j in 1..variables.len() {
+                let mut coordinate_to_index = HashMap::new();
                 let mut next_cats = Vec::with_capacity(cats.len());
                 let mut start = 0;
                 for end in 1..=cats.len() {
-                    let dv1 = &variables[j].values[cats[start].1.start];
+                    let dv1 = &variables[j].values[cats[start].leaves.start];
                     if end < cats.len()
-                        && variables[j].values[cats[end].1.clone()]
+                        && variables[j].values[cats[end].leaves.clone()]
                             .iter()
                             .all(|dv| &dv.value == &dv1.value)
                     {
                     } else {
                         let name = variables[j].map.lookup(dv1);
-                        if end - start > 1 || name.is_number_or(|s| s.is_empty()) {
+                        let next_cat = if end - start > 1 || name.is_number_or(|s| s.is_empty()) {
                             let name = variables[j].new_name(dv1, footnotes);
                             let mut group = Group::new(name);
                             for i in start..end {
-                                group.push(cats[i].0.clone());
+                                group.push(cats[i].category.clone());
+                            }
+                            CatBuilder {
+                                category: Category::from(group),
+                                leaves: cats[start].leaves.start..cats[end - 1].leaves.end,
+                                location: cats[start].location.parent(),
                             }
-                            next_cats.push((
-                                Category::from(group),
-                                cats[start].1.start..cats[end - 1].1.end,
-                            ));
                         } else {
-                            next_cats.push(cats[start].clone());
-                        }
+                            cats[start].clone()
+                        };
+                        coordinate_to_index
+                            .insert(dv1.category().unwrap() /*XXX?*/, next_cat.location);
+                        next_cats.push(next_cat);
                         start = end;
                     }
                 }
+                *variables[j].coordinate_to_index.borrow_mut() = coordinate_to_index;
                 cats = next_cats;
             }
 
@@ -471,14 +508,17 @@ impl Visualization {
                         .as_ref()
                         .map_or_else(|| Value::empty(), |label| Value::new_user_text(label)),
                 )
-                .with_multiple(cats.into_iter().map(|(category, _range)| category))
+                .with_multiple(cats.into_iter().map(|cb| cb.category))
                 .with_show_label(show_label),
             );
+
+            for variable in &variables {
+                variable.dimension_index.set(Some(dims.len()));
+            }
             dims.push(Dim {
                 axis: a,
-                dimension: Some(dimension),
+                dimension,
                 coordinate: variables[0],
-                coordinate_to_index,
             });
         }
 
@@ -541,9 +581,8 @@ impl Visualization {
 
         struct Dim<'a> {
             axis: Axis3,
-            dimension: Option<pivot::Dimension>,
+            dimension: pivot::Dimension,
             coordinate: &'a Series,
-            coordinate_to_index: HashMap<usize, usize>,
         }
 
         let mut rotate_inner_column_labels = false;
@@ -603,13 +642,6 @@ impl Visualization {
             level_ofs += layers.len();
         }
 
-        let dimensions = dims
-            .iter_mut()
-            .map(|dim| (dim.axis, dim.dimension.take().unwrap()))
-            .collect::<Vec<_>>();
-
-        let mut pivot_table = PivotTable::new(dimensions);
-
         let cell = series.get("cell").unwrap()/*XXX*/;
         let mut coords = Vec::with_capacity(dims.len());
         let (cell_formats, format_map) = graph.interval.labeling.decode_format_map(&series);
@@ -623,14 +655,20 @@ impl Visualization {
                     LabelingChild::Footnotes(footnotes) => series.get(footnotes.variable.as_str()),
                     _ => None,
                 });
+        let mut data = HashMap::new();
         for (i, cell) in cell.values.iter().enumerate() {
             coords.clear();
             for dim in &dims {
                 // XXX indexing of values, and unwrap
                 let coordinate = dim.coordinate.values[i].category().unwrap();
-                let index = match dim.coordinate_to_index.get(&coordinate) {
-                    Some(index) => *index,
-                    None => panic!("can't find {coordinate}"),
+                let Some(index) = dim
+                    .coordinate
+                    .coordinate_to_index
+                    .borrow()
+                    .get(&coordinate)
+                    .and_then(CategoryLocator::as_leaf)
+                else {
+                    panic!("can't find {coordinate}") // XXX
                 };
                 coords.push(index);
             }
@@ -665,25 +703,342 @@ impl Visualization {
                 // A system-missing value without a footnote represents an empty cell.
             } else {
                 // XXX cell_index might be invalid?
-                pivot_table.insert(coords.as_slice(), value);
+                data.insert(coords.clone(), value);
             }
         }
-        // XXX decode_set_cell_properties
 
+        for child in &graph.facet_layout.children {
+            let FacetLayoutChild::SetCellProperties(scp) = child else {
+                continue;
+            };
+
+            #[derive(Copy, Clone, Debug, PartialEq)]
+            enum TargetType {
+                Graph,
+                Labeling,
+                Interval,
+                MajorTicks,
+            }
+
+            impl TargetType {
+                fn from_id(
+                    target: &str,
+                    graph: &Graph,
+                    major_ticks: &HashMap<&str, &MajorTicks>,
+                ) -> Option<Self> {
+                    if let Some(id) = &graph.id
+                        && id == target
+                    {
+                        Some(Self::Graph)
+                    } else if let Some(id) = &graph.interval.labeling.id
+                        && id == target
+                    {
+                        Some(Self::Labeling)
+                    } else if let Some(id) = &graph.interval.id
+                        && id == target
+                    {
+                        Some(Self::Interval)
+                    } else if major_ticks.contains_key(target) {
+                        Some(Self::MajorTicks)
+                    } else {
+                        None
+                    }
+                }
+            }
+
+            #[derive(Default)]
+            struct Target<'a> {
+                graph: Option<&'a Style>,
+                labeling: Option<&'a Style>,
+                interval: Option<&'a Style>,
+                major_ticks: Option<&'a Style>,
+                frame: Option<&'a Style>,
+                format: Option<(&'a SetFormat, Option<TargetType>)>,
+            }
+            impl<'a> Target<'a> {
+                fn decode(
+                    &self,
+                    intersect: &Intersect,
+                    look: &mut Look,
+                    series: &HashMap<&str, Series>,
+                    dims: &mut [Dim],
+                    data: &mut HashMap<Vec<usize>, Value>,
+                ) {
+                    let mut wheres = Vec::new();
+                    let mut alternating = false;
+                    for child in &intersect.children {
+                        match child {
+                            IntersectChild::Where(w) => wheres.push(w),
+                            IntersectChild::IntersectWhere(_) => {
+                                // Presumably we should do something (but we don't).
+                            }
+                            IntersectChild::Alternating => alternating = true,
+                            IntersectChild::Empty => (),
+                        }
+                    }
+
+                    match self {
+                        Self {
+                            graph: Some(_),
+                            labeling: Some(_),
+                            interval: None,
+                            major_ticks: None,
+                            frame: None,
+                            format: None,
+                        } if alternating => {
+                            let mut style = Area::Data(RowParity::Odd).default_area_style();
+                            Style::decode_area(self.labeling, self.graph, &mut style);
+                            let font_style = &mut look.areas[Area::Data(RowParity::Odd)].font_style;
+                            font_style.fg = style.font_style.fg;
+                            font_style.bg = style.font_style.bg;
+                        }
+                        Self {
+                            graph: Some(_),
+                            labeling: None,
+                            interval: None,
+                            major_ticks: None,
+                            frame: None,
+                            format: None,
+                        } => {
+                            // `graph.width` likely just sets the width of the table as a whole.
+                        }
+                        Self {
+                            graph: None,
+                            labeling: None,
+                            interval: None,
+                            major_ticks: None,
+                            frame: None,
+                            format: None,
+                        } => {
+                            // No-op.  (Presumably there's a setMetaData we don't care about.)
+                        }
+                        Self {
+                            format: Some((_, Some(TargetType::MajorTicks))),
+                            ..
+                        }
+                        | Self {
+                            major_ticks: Some(_),
+                            ..
+                        }
+                        | Self { frame: Some(_), .. }
+                            if !wheres.is_empty() =>
+                        {
+                            // Formatting for individual row or column labels.
+                            for w in &wheres {
+                                let Some(s) = series.get(w.variable.as_str()) else {
+                                    continue;
+                                };
+                                let Some(dim_index) = s.dimension_index.get() else {
+                                    continue;
+                                };
+                                let dimension = &mut dims[dim_index].dimension;
+                                let Ok(axis) = Axis2::try_from(dims[dim_index].axis) else {
+                                    continue;
+                                };
+                                for index in
+                                    w.include.split(';').filter_map(|s| s.parse::<usize>().ok())
+                                {
+                                    if let Some(locator) =
+                                        s.coordinate_to_index.borrow().get(&index).copied()
+                                        && let Some(category) = dimension.root.category_mut(locator)
+                                    {
+                                        Style::apply_to_value(
+                                            category.name_mut(),
+                                            self.format.map(|(sf, _)| sf),
+                                            self.major_ticks,
+                                            self.frame,
+                                            &look.areas[Area::Labels(axis)],
+                                        );
+                                    }
+                                }
+                            }
+                        }
+                        Self {
+                            format: Some((_, Some(TargetType::Labeling))),
+                            ..
+                        }
+                        | Self {
+                            labeling: Some(_), ..
+                        }
+                        | Self {
+                            interval: Some(_), ..
+                        } => {
+                            // Formatting for individual cells or groups of them
+                            // with some dimensions in common.
+                            let mut include = vec![HashSet::new(); dims.len()];
+                            for w in &wheres {
+                                let Some(s) = series.get(w.variable.as_str()) else {
+                                    continue;
+                                };
+                                let Some(dim_index) = s.dimension_index.get() else {
+                                    // Group indexes may be included even though
+                                    // they are redundant.  Ignore them.
+                                    continue;
+                                };
+                                let dimension = &mut dims[dim_index].dimension;
+                                for index in
+                                    w.include.split(';').filter_map(|s| s.parse::<usize>().ok())
+                                {
+                                    if let Some(locator) =
+                                        s.coordinate_to_index.borrow().get(&index).copied()
+                                        && let Some(leaf_index) = locator.as_leaf()
+                                    {
+                                        include[dim_index].insert(leaf_index);
+                                    }
+                                }
+                            }
+
+                            // XXX This is inefficient in the common case where
+                            // all of the dimensions are matched.  We should use
+                            // a heuristic where if all of the dimensions are
+                            // matched and the product of n[*] is less than the
+                            // number of cells then iterate through all the
+                            // possibilities rather than all the cells.  Or even
+                            // only do it if there is just one possibility.
+                            for (indexes, value) in data {
+                                let mut skip = false;
+                                for (dimension, index) in indexes.iter().enumerate() {
+                                    if !include[dimension].is_empty()
+                                        && !include[dimension].contains(index)
+                                    {
+                                        skip = true;
+                                        break;
+                                    }
+                                }
+                                if !skip {
+                                    Style::apply_to_value(
+                                        value,
+                                        self.format.map(|(sf, _)| sf),
+                                        self.major_ticks,
+                                        self.frame,
+                                        &look.areas[Area::Data(RowParity::Even)],
+                                    );
+                                }
+                            }
+                        }
+                        _ => (),
+                    }
+                }
+            }
+            let mut target = Target::default();
+            for set in &scp.sets {
+                match set {
+                    Set::SetStyle(set_style) => {
+                        if let Some(style) = set_style.style.get(&styles) {
+                            match TargetType::from_id(&set_style.target, graph, &major_ticks) {
+                                Some(TargetType::Graph) => target.graph = Some(style),
+                                Some(TargetType::Interval) => target.interval = Some(style),
+                                Some(TargetType::Labeling) => target.labeling = Some(style),
+                                Some(TargetType::MajorTicks) => target.major_ticks = Some(style),
+                                None => (),
+                            }
+                        }
+                    }
+                    Set::SetFrameStyle(set_frame_style) => {
+                        target.frame = set_frame_style.style.get(&styles)
+                    }
+                    Set::SetFormat(sf) => {
+                        let target_type = TargetType::from_id(&sf.target, graph, &major_ticks);
+                        target.format = Some((sf, target_type))
+                    }
+                    Set::SetMetaData(_) => (),
+                }
+            }
+
+            match (
+                scp.union_.as_ref(),
+                scp.apply_to_converse.unwrap_or_default(),
+            ) {
+                (Some(union_), false) => {
+                    for intersect in &union_.intersects {
+                        target.decode(
+                            intersect,
+                            &mut look,
+                            &series,
+                            dims.as_mut_slice(),
+                            &mut data,
+                        );
+                    }
+                }
+                (Some(_), true) => {
+                    // Not implemented, not seen in the corpus.
+                }
+                (None, true) => {
+                    if target
+                        .format
+                        .is_some_and(|(_sf, target_type)| target_type == Some(TargetType::Labeling))
+                        || target.labeling.is_some()
+                        || target.interval.is_some()
+                    {
+                        for value in data.values_mut() {
+                            Style::apply_to_value(
+                                value,
+                                target.format.map(|(sf, _target_type)| sf),
+                                None,
+                                None,
+                                &look.areas[Area::Data(RowParity::Even)],
+                            );
+                        }
+                    }
+                }
+                (None, false) => {
+                    // Appears to be used to set the font for something—but what?
+                }
+            }
+        }
+
+        let dimensions = dims
+            .into_iter()
+            .map(|dim| (dim.axis, dim.dimension))
+            .collect::<Vec<_>>();
+        let mut pivot_table = PivotTable::new(dimensions)
+            .with_look(Arc::new(look))
+            .with_data(data);
+        if let Some(title) = title {
+            pivot_table = pivot_table.with_title(title);
+        }
+        if let Some(caption) = caption {
+            pivot_table = pivot_table.with_caption(caption);
+        }
         Ok(pivot_table)
     }
 }
 
 struct Series {
+    name: String,
     label: Option<String>,
     format: crate::format::Format,
     remapped: bool,
     values: Vec<DataValue>,
     map: Map,
     affixes: Vec<Affix>,
+    coordinate_to_index: RefCell<HashMap<usize, CategoryLocator>>,
+    dimension_index: Cell<Option<usize>>,
 }
 
 impl Series {
+    fn new(name: String, values: Vec<DataValue>, map: Map) -> Self {
+        Self {
+            name,
+            label: None,
+            format: F8_0,
+            remapped: false,
+            values,
+            map,
+            affixes: Vec::new(),
+            coordinate_to_index: Default::default(),
+            dimension_index: Default::default(),
+        }
+    }
+    fn with_format(self, format: crate::format::Format) -> Self {
+        Self { format, ..self }
+    }
+    fn with_label(self, label: Option<String>) -> Self {
+        Self { label, ..self }
+    }
+    fn with_affixes(self, affixes: Vec<Affix>) -> Self {
+        Self { affixes, ..self }
+    }
     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)
@@ -798,14 +1153,10 @@ impl SourceVariable {
         } else if let Some(label_series) = label_series {
             map.insert_labels(&data, label_series, format);
         }
-        Ok(Series {
-            label: self.label.clone(),
-            format,
-            remapped: false,
-            values: data,
-            map,
-            affixes,
-        })
+        Ok(Series::new(self.id.clone(), data, map)
+            .with_format(format)
+            .with_affixes(affixes)
+            .with_label(self.label.clone()))
     }
 }
 
@@ -866,14 +1217,7 @@ impl DerivedVariable {
         {
             values.clear();
         }
-        Ok(Series {
-            label: None,
-            format: F8_0,
-            remapped: false,
-            values,
-            map,
-            affixes: Vec::new(),
-        })
+        Ok(Series::new(self.id.clone(), values, map))
     }
 }
 
@@ -1507,43 +1851,131 @@ struct Style {
 }
 
 impl Style {
-    fn decode(fg: Option<&Style>, bg: Option<&Style>, out: &mut AreaStyle) {
+    fn apply_to_value(
+        value: &mut Value,
+        sf: Option<&SetFormat>,
+        fg: Option<&Style>,
+        bg: Option<&Style>,
+        base_style: &AreaStyle,
+    ) {
+        if let Some(sf) = sf {
+            if sf.reset == Some(true) {
+                value.styling_mut().footnotes.clear();
+            }
+
+            let format = match &sf.child {
+                Some(SetFormatChild::Format(format)) => Some(format.decode()),
+                Some(SetFormatChild::NumberFormat(format)) => {
+                    Some(SignificantNumberFormat::from(format).decode())
+                }
+                Some(SetFormatChild::StringFormat(format)) => None,
+                Some(SetFormatChild::DateTimeFormat(format)) => Some(format.decode()),
+                Some(SetFormatChild::ElapsedTimeFormat(format)) => Some(format.decode()),
+                None => None,
+            };
+            if let Some(format) = format {
+                match &mut value.inner {
+                    ValueInner::Number(number) => {
+                        number.format = format;
+                    }
+                    ValueInner::String(string) => {
+                        if format.type_().category() == format::Category::Date
+                            && let Ok(date_time) =
+                                NaiveDateTime::parse_from_str(&string.s, "%Y-%m-%dT%H:%M:%S%.3f")
+                        {
+                            value.inner = ValueInner::Number(NumberValue {
+                                show: None,
+                                format,
+                                honor_small: false,
+                                value: Some(date_time_to_pspp(date_time)),
+                                variable: None,
+                                value_label: None,
+                            })
+                        } else if format.type_().category() == format::Category::Time
+                            && let Ok(time) = NaiveTime::parse_from_str(&string.s, "%H:%M:%S%.3f")
+                        {
+                            value.inner = ValueInner::Number(NumberValue {
+                                show: None,
+                                format,
+                                honor_small: false,
+                                value: Some(time_to_pspp(time)),
+                                variable: None,
+                                value_label: None,
+                            })
+                        } else if let Ok(number) = string.s.parse::<f64>() {
+                            value.inner = ValueInner::Number(NumberValue {
+                                show: None,
+                                format,
+                                honor_small: false,
+                                value: Some(number),
+                                variable: None,
+                                value_label: None,
+                            })
+                        }
+                    }
+                    _ => (),
+                }
+            }
+        }
+
+        if fg.is_some() || bg.is_some() {
+            let mut styling = value.styling_mut();
+            let font_style = styling
+                .font_style
+                .get_or_insert_with(|| base_style.font_style.clone());
+            let cell_style = styling
+                .cell_style
+                .get_or_insert_with(|| base_style.cell_style.clone());
+            Self::decode(fg, bg, cell_style, font_style);
+        }
+    }
+
+    fn decode(
+        fg: Option<&Style>,
+        bg: Option<&Style>,
+        cell_style: &mut CellStyle,
+        font_style: &mut pivot::FontStyle,
+    ) {
         if let Some(fg) = fg {
             if let Some(weight) = fg.font_weight {
-                out.font_style.bold = weight.is_bold();
+                font_style.bold = weight.is_bold();
             }
             if let Some(style) = fg.font_style {
-                out.font_style.italic = style.is_italic();
+                font_style.italic = style.is_italic();
             }
             if let Some(underline) = fg.font_underline {
-                out.font_style.underline = underline.is_underline();
+                font_style.underline = underline.is_underline();
             }
             if let Some(color) = fg.color {
-                out.font_style.fg = color;
+                font_style.fg = color;
             }
             if let Some(font_size) = &fg.font_size {
                 if let Ok(size) = font_size
                     .trim_end_matches(|c: char| c.is_alphabetic())
                     .parse()
                 {
-                    out.font_style.size = size;
+                    font_style.size = size;
                 } else {
                     // XXX warn?
                 }
             }
             if let Some(alignment) = fg.text_alignment {
-                out.cell_style.horz_align = alignment.as_horz_align(fg.decimal_offset);
+                cell_style.horz_align = alignment.as_horz_align(fg.decimal_offset);
             }
             if let Some(label_local_vertical) = fg.label_location_vertical {
-                out.cell_style.vert_align = label_local_vertical.into();
+                cell_style.vert_align = label_local_vertical.into();
             }
         }
         if let Some(bg) = bg {
             if let Some(color) = bg.color {
-                out.font_style.bg = color;
+                font_style.bg = color;
             }
         }
     }
+
+    fn decode_area(fg: Option<&Style>, bg: Option<&Style>, out: &mut AreaStyle) {
+        Self::decode(fg, bg, &mut out.cell_style, &mut out.font_style);
+    }
 }
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
@@ -1641,6 +2073,9 @@ impl From<LabelLocation> for VertAlign {
 #[derive(Deserialize, Debug)]
 #[serde(rename_all = "camelCase")]
 struct Graph {
+    #[serde(rename = "@id")]
+    id: Option<String>,
+
     #[serde(rename = "@cellStyle")]
     cell_style: Ref<Style>,
 
@@ -1806,7 +2241,7 @@ struct SetCellProperties {
     sets: Vec<Set>,
 
     #[serde(rename = "union")]
-    unions: Option<Union>,
+    union_: Option<Union>,
 }
 
 #[derive(Deserialize, Debug)]
@@ -1820,7 +2255,7 @@ struct Union {
 #[serde(rename_all = "camelCase")]
 struct Intersect {
     #[serde(default, rename = "$value")]
-    child: Vec<IntersectChild>,
+    children: Vec<IntersectChild>,
 }
 
 #[derive(Deserialize, Debug)]
@@ -1933,6 +2368,9 @@ struct Interval {
 #[derive(Deserialize, Debug)]
 #[serde(rename_all = "camelCase")]
 struct Labeling {
+    #[serde(rename = "@id")]
+    id: Option<String>,
+
     #[serde(rename = "@style")]
     style: Option<Ref<Style>>,
 
@@ -2120,7 +2558,7 @@ impl Label {
     fn decode_style(&self, area_style: &mut AreaStyle, styles: &HashMap<&str, &Style>) {
         let fg = self.style.get(styles);
         let bg = self.text_frame_style.as_ref().and_then(|r| r.get(styles));
-        Style::decode(fg, bg, area_style);
+        Style::decode_area(fg, bg, area_style);
     }
 }
 #[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, Enum)]
@@ -2210,22 +2648,23 @@ struct LabelFrame {
 }
 
 impl LabelFrame {
-    fn decode(&self, look: &mut Look, styles: &HashMap<&str, &Style>) {
-        let Some(label) = &self.label else { return };
-        let Some(purpose) = label.purpose else { return };
-        let area = match purpose {
-            Purpose::Title => Area::Title,
-            Purpose::SubTitle => Area::Caption,
-            Purpose::SubSubTitle => return,
-            Purpose::Layer => Area::Layers,
-            Purpose::Footnote => Area::Footer,
-        };
-        Style::decode(
-            label.style.get(styles),
-            label.text_frame_style.as_ref().and_then(|r| r.get(styles)),
-            &mut look.areas[area],
-        );
-        todo!()
+    fn decode_label(labels: &[&Label]) -> Option<Value> {
+        if !labels.is_empty() {
+            let mut s = String::new();
+            for t in labels {
+                if let LabelChild::Text(text) = &t.child {
+                    for t in text {
+                        if let Some(defines_reference) = t.defines_reference {
+                            // XXX footnote
+                        }
+                        s += &t.text;
+                    }
+                }
+            }
+            Some(Value::new_user_text(s))
+        } else {
+            None
+        }
     }
 }