Move spv reader and writer to top-level module.
authorBen Pfaff <blp@cs.stanford.edu>
Wed, 10 Dec 2025 19:25:10 +0000 (11:25 -0800)
committerBen Pfaff <blp@cs.stanford.edu>
Wed, 10 Dec 2025 19:25:10 +0000 (11:25 -0800)
23 files changed:
rust/pspp/src/cli/convert.rs
rust/pspp/src/cli/show_spv.rs
rust/pspp/src/output.rs
rust/pspp/src/output/drivers/cairo/driver.rs
rust/pspp/src/output/drivers/cairo/fsm.rs
rust/pspp/src/output/drivers/cairo/pager.rs
rust/pspp/src/output/page.rs
rust/pspp/src/output/pivot.rs
rust/pspp/src/output/spv.rs [deleted file]
rust/pspp/src/output/spv/css.rs [deleted file]
rust/pspp/src/output/spv/html.rs [deleted file]
rust/pspp/src/output/spv/legacy_bin.rs [deleted file]
rust/pspp/src/output/spv/legacy_xml.rs [deleted file]
rust/pspp/src/output/spv/light.rs [deleted file]
rust/pspp/src/output/table.rs
rust/pspp/src/spv.rs
rust/pspp/src/spv/read.rs [new file with mode: 0644]
rust/pspp/src/spv/read/css.rs [new file with mode: 0644]
rust/pspp/src/spv/read/html.rs [new file with mode: 0644]
rust/pspp/src/spv/read/legacy_bin.rs [new file with mode: 0644]
rust/pspp/src/spv/read/legacy_xml.rs [new file with mode: 0644]
rust/pspp/src/spv/read/light.rs [new file with mode: 0644]
rust/pspp/src/spv/write.rs

index 26dc2029640341abd5be47d48d3f00f21133ae56..3df207134de03418c62d2b94327a68de1c903073 100644 (file)
@@ -23,7 +23,7 @@ use pspp::{
     data::{ByteString, Case, Datum},
     dictionary::Dictionary,
     file::FileType,
-    output::{Criteria, drivers::Driver, spv},
+    output::{Criteria, drivers::Driver},
     pc::PcFile,
     por::PortableFile,
     sys::ReadOptions,
@@ -143,7 +143,7 @@ impl Convert {
                 self.write_data(dictionary, cases)
             }
             Some(FileType::Viewer { .. }) => {
-                let (items, page_setup) = spv::ReadOptions::new()
+                let (items, page_setup) = pspp::spv::read::ReadOptions::new()
                     .with_password(self.password.clone())
                     .open_file(&self.input)?
                     .into_parts();
index 631c3177c822c7dbd249ebd78eaa56940a257d35..2a068602e6fecedaaba81996cb1c3c7856088a50 100644 (file)
@@ -16,7 +16,7 @@
 
 use anyhow::Result;
 use clap::{Args, ValueEnum};
-use pspp::output::{Criteria, Item, spv};
+use pspp::output::{Criteria, Item};
 use std::{fmt::Display, path::PathBuf};
 
 /// Show information about SPSS viewer files (SPV files).
@@ -90,7 +90,7 @@ impl ShowSpv {
     pub fn run(self) -> Result<()> {
         match self.mode {
             Mode::Directory => {
-                let item = spv::ReadOptions::new()
+                let item = pspp::spv::read::ReadOptions::new()
                     .with_password(self.password)
                     .open_file(&self.input)?
                     .into_items();
@@ -101,7 +101,7 @@ impl ShowSpv {
                 Ok(())
             }
             Mode::View => {
-                let item = spv::ReadOptions::new()
+                let item = pspp::spv::read::ReadOptions::new()
                     .with_password(self.password)
                     .open_file(&self.input)?
                     .into_items();
index 12531162104ae24228c05deb99d5fe153c463259..225051e6e2b508209bd67e404cabfb1f42e10c7c 100644 (file)
@@ -46,7 +46,6 @@ pub mod drivers;
 pub mod page;
 pub mod pivot;
 pub mod render;
-pub mod spv;
 pub mod table;
 
 /// A single output item.
index 1fa4c3e0190f1ce695ab55851f7311c51faca71e..8b8b29089fd227b88d6250029150cf66bddea247 100644 (file)
@@ -27,18 +27,20 @@ use pango::SCALE;
 use paper_sizes::Unit;
 use serde::{Deserialize, Serialize};
 
-use crate::output::{
-    Details, Item, ItemCursor, TextType,
-    drivers::{
-        Driver,
-        cairo::{
-            fsm::{CairoFsmStyle, parse_font_style},
-            pager::{CairoPageStyle, CairoPager},
+use crate::{
+    output::{
+        Details, Item, ItemCursor, TextType,
+        drivers::{
+            Driver,
+            cairo::{
+                fsm::{CairoFsmStyle, parse_font_style},
+                pager::{CairoPageStyle, CairoPager},
+            },
         },
+        page::PageSetup,
+        pivot::{Color, Coord2, FontStyle},
     },
-    page::PageSetup,
-    pivot::{Color, Coord2, FontStyle},
-    spv::html::Variable,
+    spv::read::html::Variable,
 };
 
 use crate::output::pivot::Axis2;
index 5c411ec1d9927404b007d61803c11de5884bebaf..1d92e12ec414f2f5f419fe651d6fe47ca89da5e7 100644 (file)
@@ -29,10 +29,10 @@ use smallvec::{SmallVec, smallvec};
 use crate::output::drivers::cairo::{px_to_xr, xr_to_pt};
 use crate::output::pivot::{Axis2, BorderStyle, Coord2, FontStyle, HorzAlign, Rect2, Stroke};
 use crate::output::render::{Device, Extreme, Pager, Params};
-use crate::output::spv::html::Markup;
 use crate::output::table::DrawCell;
 use crate::output::{Details, Item};
 use crate::output::{pivot::Color, table::Content};
+use crate::spv::read::html::Markup;
 
 /// Width of an ordinary line.
 const LINE_WIDTH: isize = LINE_SPACE / 2;
index 6cb1152992938ae7553b5ce98e754d1cf4feb2f6..6811646c26d5e60e2472f681f47f584658e697ec 100644 (file)
@@ -20,16 +20,12 @@ use cairo::{Context, RecordingSurface};
 use enum_map::EnumMap;
 use pango::Layout;
 
-use crate::output::{
-    Item,
+use crate::{output::{
     drivers::cairo::{
         fsm::{CairoFsm, CairoFsmStyle},
         xr_to_pt,
-    },
-    pivot::{Axis2, CellStyle, FontStyle, Rect2, ValueOptions},
-    spv::html::{Document, Variable},
-    table::DrawCell,
-};
+    }, pivot::{Axis2, CellStyle, FontStyle, Rect2, ValueOptions}, table::DrawCell, Item
+}, spv::read::html::{Document, Variable}};
 
 #[derive(Clone, Debug)]
 pub struct CairoPageStyle {
index d8ea2e67d9ca98281bba3443a7970302314207c2..611a70442a1083b351fd6f68738d71c98a3476bd 100644 (file)
@@ -20,7 +20,7 @@ use enum_map::{EnumMap, enum_map};
 use paper_sizes::{Catalog, Length, PaperSize, Unit};
 use serde::{Deserialize, Deserializer, Serialize, de::Error};
 
-use crate::output::spv::html::Document;
+use crate::spv::read::html::Document;
 
 use super::pivot::Axis2;
 
index 3a60b2d6d99ab26d3ef9548e93be8e3ee8d0ac83..2b588301c4aa7991a67309519275cfee34e34afe 100644 (file)
@@ -72,16 +72,9 @@ pub use tlo::parse_bool;
 use tlo::parse_tlo;
 
 use crate::{
-    calendar::date_time_to_pspp,
-    data::{ByteString, Datum, EncodedString},
-    format::{
-        DATETIME40_0, Decimal, F8_2, F40, F40_2, F40_3, Format, PCT40_1,
-        Settings as FormatSettings, Type, UncheckedFormat,
-    },
-    output::spv::html::Markup,
-    settings::{Settings, Show},
-    util::ToSmallString,
-    variable::{VarType, Variable},
+    calendar::date_time_to_pspp, data::{ByteString, Datum, EncodedString}, format::{
+        Decimal, Format, Settings as FormatSettings, Type, UncheckedFormat, DATETIME40_0, F40, F40_2, F40_3, F8_2, PCT40_1
+    }, settings::{Settings, Show}, spv::read::html::Markup, util::ToSmallString, variable::{VarType, Variable}
 };
 
 pub mod output;
diff --git a/rust/pspp/src/output/spv.rs b/rust/pspp/src/output/spv.rs
deleted file mode 100644 (file)
index 7be381b..0000000
+++ /dev/null
@@ -1,774 +0,0 @@
-// 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/>.
-
-use std::{
-    fs::File,
-    io::{BufReader, Cursor, Read, Seek},
-    path::Path,
-};
-
-use anyhow::{Context, anyhow};
-use binrw::{BinRead, error::ContextExt};
-use cairo::ImageSurface;
-use displaydoc::Display;
-use paper_sizes::PaperSize;
-use serde::Deserialize;
-use zip::{ZipArchive, result::ZipError};
-
-use crate::{
-    crypto::EncryptedFile,
-    output::{
-        Details, Item, SpvInfo, SpvMembers, Text,
-        page::{self},
-        pivot::{Axis2, Length, Look, TableProperties, Value},
-        spv::{
-            html::Document,
-            legacy_bin::LegacyBin,
-            legacy_xml::Visualization,
-            light::{LightError, LightTable},
-        },
-    },
-};
-
-mod css;
-pub mod html;
-mod legacy_bin;
-mod legacy_xml;
-mod light;
-
-/// Options for reading an SPV file.
-#[derive(Clone, Debug, Default)]
-pub struct ReadOptions {
-    /// Password to use to unlock an encrypted SPV file.
-    ///
-    /// For an encrypted SPV file, this must be set to the (encoded or
-    /// unencoded) password.
-    ///
-    /// For a plaintext SPV file, this must be None.
-    pub password: Option<String>,
-}
-
-impl ReadOptions {
-    /// Construct a new [ReadOptions] without a password.
-    pub fn new() -> Self {
-        Self::default()
-    }
-
-    /// Causes the file to be read by decrypting it with the given `password` or
-    /// without decrypting if `password` is None.
-    pub fn with_password(self, password: Option<String>) -> Self {
-        Self { password }
-    }
-
-    /// Opens the file at `path`.
-    pub fn open_file<P>(mut self, path: P) -> Result<SpvFile, anyhow::Error>
-    where
-        P: AsRef<Path>,
-    {
-        let file = File::open(path)?;
-        if let Some(password) = self.password.take() {
-            self.open_reader_encrypted(file, password)
-        } else {
-            Self::open_reader_inner(file)
-        }
-    }
-
-    /// Opens the file read from `reader`.
-    fn open_reader_encrypted<R>(self, reader: R, password: String) -> Result<SpvFile, anyhow::Error>
-    where
-        R: Read + Seek + 'static,
-    {
-        Self::open_reader_inner(
-            EncryptedFile::new(reader)?
-                .unlock(password.as_bytes())
-                .map_err(|_| anyhow!("Incorrect password."))?,
-        )
-    }
-
-    /// Opens the file read from `reader`.
-    pub fn open_reader<R>(mut self, reader: R) -> Result<SpvFile, anyhow::Error>
-    where
-        R: Read + Seek + 'static,
-    {
-        if let Some(password) = self.password.take() {
-            self.open_reader_encrypted(reader, password)
-        } else {
-            Self::open_reader_inner(reader)
-        }
-    }
-
-    fn open_reader_inner<R>(reader: R) -> Result<SpvFile, anyhow::Error>
-    where
-        R: Read + Seek + 'static,
-    {
-        // Open archive.
-        let mut archive = ZipArchive::new(reader).map_err(|error| match error {
-            ZipError::InvalidArchive(_) => Error::NotSpv,
-            other => other.into(),
-        })?;
-        Ok(Self::from_spv_zip_archive(&mut archive)?)
-    }
-
-    fn from_spv_zip_archive<R>(archive: &mut ZipArchive<R>) -> Result<SpvFile, Error>
-    where
-        R: Read + Seek,
-    {
-        // Check manifest.
-        let mut file = archive
-            .by_name("META-INF/MANIFEST.MF")
-            .map_err(|_| Error::NotSpv)?;
-        let mut string = String::new();
-        file.read_to_string(&mut string)?;
-        if string.trim() != "allowPivoting=true" {
-            return Err(Error::NotSpv);
-        }
-        drop(file);
-
-        let mut items = Vec::new();
-        let mut page_setup = None;
-        for i in 0..archive.len() {
-            let name = String::from(archive.name_for_index(i).unwrap());
-            if name.starts_with("outputViewer") && name.ends_with(".xml") {
-                let (mut new_items, ps) = read_heading(archive, i, &name)?;
-                items.append(&mut new_items);
-                page_setup = page_setup.or(ps);
-            }
-        }
-
-        Ok(SpvFile {
-            item: items.into_iter().collect(),
-            page_setup,
-        })
-    }
-}
-
-pub struct SpvFile {
-    /// SPV file contents.
-    pub item: Vec<Item>,
-
-    /// The page setup in the SPV file, if any.
-    pub page_setup: Option<page::PageSetup>,
-}
-
-impl SpvFile {
-    pub fn into_parts(self) -> (Vec<Item>, Option<page::PageSetup>) {
-        (self.item, self.page_setup)
-    }
-
-    pub fn into_items(self) -> Vec<Item> {
-        self.item
-    }
-}
-
-#[derive(Debug, Display, thiserror::Error)]
-pub enum Error {
-    /// Not an SPV file.
-    NotSpv,
-
-    /// {0}
-    ZipError(#[from] ZipError),
-
-    /// {0}
-    IoError(#[from] std::io::Error),
-
-    /// {0}
-    DeError(#[from] quick_xml::DeError),
-
-    /// {0}
-    BinrwError(#[from] binrw::Error),
-
-    /// {0}
-    LightError(#[from] LightError),
-
-    /// {0}
-    CairoError(#[from] cairo::IoError),
-}
-
-fn new_error_item(message: impl Into<Value>) -> Item {
-    Text::new_log(message).into_item().with_label("Error")
-}
-
-fn read_heading<R>(
-    archive: &mut ZipArchive<R>,
-    file_number: usize,
-    structure_member: &str,
-) -> Result<(Vec<Item>, Option<page::PageSetup>), Error>
-where
-    R: Read + Seek,
-{
-    let member = BufReader::new(archive.by_index(file_number)?);
-    let mut heading: Heading = match serde_path_to_error::deserialize(
-        &mut quick_xml::de::Deserializer::from_reader(member),
-    )
-    .with_context(|| format!("Failed to parse {structure_member}"))
-    {
-        Ok(result) => result,
-        Err(error) => panic!("{error:?}"),
-    };
-    let _page_setup = heading.page_setup.take();
-    // XXX convert page_setup to the internal format
-    Ok((heading.decode(archive, structure_member)?, None))
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Heading {
-    #[serde(rename = "@visibility")]
-    visibility: Option<String>,
-    #[serde(rename = "@commandName")]
-    command_name: Option<String>,
-    label: Label,
-    page_setup: Option<PageSetup>,
-
-    #[serde(rename = "$value")]
-    #[serde(default)]
-    children: Vec<HeadingContent>,
-}
-
-impl Heading {
-    fn decode<R>(
-        self,
-        archive: &mut ZipArchive<R>,
-        structure_member: &str,
-    ) -> Result<Vec<Item>, Error>
-    where
-        R: Read + Seek,
-    {
-        let mut items = Vec::new();
-        for child in self.children {
-            match child {
-                HeadingContent::Container(container) => {
-                    if container.page_break_before == PageBreakBefore::Always {
-                        items.push(
-                            Details::PageBreak
-                                .into_item()
-                                .with_spv_info(SpvInfo::new(structure_member)),
-                        );
-                    }
-                    let item = match container.content {
-                        ContainerContent::Table(table) => {
-                            table.decode(archive, structure_member).unwrap() /* XXX*/
-                        }
-                        ContainerContent::Graph(graph) => graph.decode(structure_member),
-                        ContainerContent::Text(container_text) => Text::new(
-                            match container_text.text_type {
-                                TextType::Title => crate::output::TextType::Title,
-                                TextType::Log | TextType::Text => crate::output::TextType::Log,
-                                TextType::PageTitle => crate::output::TextType::PageTitle,
-                            },
-                            container_text.decode(),
-                        )
-                        .into_item()
-                        .with_command_name(container_text.command_name)
-                        .with_spv_info(SpvInfo::new(structure_member)),
-                        ContainerContent::Image(image) => {
-                            image.decode(archive, structure_member).unwrap()
-                        } /*XXX*/,
-                        ContainerContent::Object(object) => {
-                            object.decode(archive, structure_member).unwrap()
-                        } /*XXX*/,
-                        ContainerContent::Model => new_error_item("models not yet implemented")
-                            .with_spv_info(SpvInfo::new(structure_member).with_error()),
-                        ContainerContent::Tree => new_error_item("trees not yet implemented")
-                            .with_spv_info(SpvInfo::new(structure_member).with_error()),
-                    };
-                    items.push(item.with_show(container.visibility == Visibility::Visible));
-                }
-                HeadingContent::Heading(mut heading) => {
-                    let show = !heading.visibility.is_some();
-                    let label = std::mem::take(&mut heading.label.text);
-                    let command_name = heading.command_name.take();
-                    items.push(
-                        heading
-                            .decode(archive, structure_member)?
-                            .into_iter()
-                            .collect::<Item>()
-                            .with_show(show)
-                            .with_label(label)
-                            .with_command_name(command_name)
-                            .with_spv_info(SpvInfo::new(structure_member)),
-                    );
-                }
-            }
-        }
-        Ok(items)
-    }
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct PageSetup {
-    #[serde(rename = "@initial-page-number")]
-    pub initial_page_number: Option<i32>,
-    #[serde(rename = "@chart-size")]
-    pub chart_size: Option<ChartSize>,
-    #[serde(rename = "@margin-left")]
-    pub margin_left: Option<Length>,
-    #[serde(rename = "@margin-right")]
-    pub margin_right: Option<Length>,
-    #[serde(rename = "@margin-top")]
-    pub margin_top: Option<Length>,
-    #[serde(rename = "@margin-bottom")]
-    pub margin_bottom: Option<Length>,
-    #[serde(rename = "@paper-height")]
-    pub paper_height: Option<Length>,
-    #[serde(rename = "@paper-width")]
-    pub paper_width: Option<Length>,
-    #[serde(rename = "@reference-orientation")]
-    pub reference_orientation: Option<ReferenceOrientation>,
-    #[serde(rename = "@space-after")]
-    pub space_after: Option<Length>,
-    pub page_header: PageHeader,
-    pub page_footer: PageFooter,
-}
-
-impl PageSetup {
-    fn decode(&self) -> page::PageSetup {
-        let mut setup = page::PageSetup::default();
-        if let Some(initial_page_number) = self.initial_page_number {
-            setup.initial_page_number = initial_page_number;
-        }
-        if let Some(chart_size) = self.chart_size {
-            setup.chart_size = chart_size.into();
-        }
-        if let Some(margin_left) = self.margin_left {
-            setup.margins.0[Axis2::X][0] = margin_left.into();
-        }
-        if let Some(margin_right) = self.margin_right {
-            setup.margins.0[Axis2::X][1] = margin_right.into();
-        }
-        if let Some(margin_top) = self.margin_top {
-            setup.margins.0[Axis2::Y][0] = margin_top.into();
-        }
-        if let Some(margin_bottom) = self.margin_bottom {
-            setup.margins.0[Axis2::Y][1] = margin_bottom.into();
-        }
-        match (self.paper_width, self.paper_height) {
-            (Some(width), Some(height)) => {
-                setup.paper = PaperSize::new(width.0, height.0, paper_sizes::Unit::Inch)
-            }
-            (Some(length), None) | (None, Some(length)) => {
-                setup.paper = PaperSize::new(length.0, length.0, paper_sizes::Unit::Inch)
-            }
-            (None, None) => (),
-        }
-        if let Some(reference_orientation) = self.reference_orientation {
-            setup.orientation = reference_orientation.into();
-        }
-        if let Some(space_after) = self.space_after {
-            setup.object_spacing = space_after.into();
-        }
-        if let Some(PageParagraph { text }) = &self.page_header.page_paragraph {
-            setup.header = text.decode();
-        }
-        if let Some(PageParagraph { text }) = &self.page_footer.page_paragraph {
-            setup.footer = text.decode();
-        }
-        setup
-    }
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct PageHeader {
-    page_paragraph: Option<PageParagraph>,
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct PageFooter {
-    page_paragraph: Option<PageParagraph>,
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct PageParagraph {
-    text: PageParagraphText,
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(rename_all = "camelCase")]
-struct PageParagraphText {
-    #[serde(default, rename = "$text")]
-    text: String,
-}
-
-impl PageParagraphText {
-    fn decode(&self) -> Document {
-        Document::from_html(&self.text)
-    }
-}
-
-#[derive(Copy, Clone, Debug, Default, Deserialize)]
-#[serde(rename = "snake_case")]
-pub enum ReferenceOrientation {
-    #[serde(alias = "0")]
-    #[serde(alias = "0deg")]
-    #[serde(alias = "inherit")]
-    #[default]
-    Portrait,
-
-    #[serde(alias = "90")]
-    #[serde(alias = "90deg")]
-    #[serde(alias = "-270")]
-    #[serde(alias = "-270deg")]
-    Landscape,
-
-    #[serde(alias = "180")]
-    #[serde(alias = "180deg")]
-    #[serde(alias = "-1280")]
-    #[serde(alias = "-180deg")]
-    ReversePortrait,
-
-    #[serde(alias = "270")]
-    #[serde(alias = "270deg")]
-    #[serde(alias = "-90")]
-    #[serde(alias = "-90deg")]
-    Seascape,
-}
-
-impl From<ReferenceOrientation> for page::Orientation {
-    fn from(value: ReferenceOrientation) -> Self {
-        match value {
-            ReferenceOrientation::Portrait | ReferenceOrientation::ReversePortrait => {
-                page::Orientation::Portrait
-            }
-            ReferenceOrientation::Landscape | ReferenceOrientation::Seascape => {
-                page::Orientation::Landscape
-            }
-        }
-    }
-}
-
-/// Chart size.
-#[derive(Copy, Clone, Debug, Default, Deserialize)]
-pub enum ChartSize {
-    #[default]
-    #[serde(rename = "as-is")]
-    AsIs,
-
-    #[serde(rename = "full-height")]
-    FullHeight,
-
-    #[serde(rename = "half-height")]
-    HalfHeight,
-
-    #[serde(rename = "quarter-height")]
-    QuarterHeight,
-}
-
-impl From<ChartSize> for page::ChartSize {
-    fn from(value: ChartSize) -> Self {
-        match value {
-            ChartSize::AsIs => page::ChartSize::AsIs,
-            ChartSize::FullHeight => page::ChartSize::FullHeight,
-            ChartSize::HalfHeight => page::ChartSize::HalfHeight,
-            ChartSize::QuarterHeight => page::ChartSize::QuarterHeight,
-        }
-    }
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-enum HeadingContent {
-    Container(Container),
-    Heading(Box<Heading>),
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Label {
-    #[serde(default, rename = "$text")]
-    text: String,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Container {
-    #[serde(default, rename = "@visibility")]
-    visibility: Visibility,
-    #[serde(rename = "@page-break-before")]
-    #[serde(default)]
-    page_break_before: PageBreakBefore,
-    #[serde(rename = "@text-align")]
-    text_align: Option<TextAlign>,
-    #[serde(rename = "@width")]
-    width: Option<String>,
-    label: Label,
-
-    #[serde(rename = "$value")]
-    content: ContainerContent,
-}
-
-#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "camelCase")]
-enum PageBreakBefore {
-    #[default]
-    Auto,
-    Always,
-    Avoid,
-    Left,
-    Right,
-    Inherit,
-}
-
-#[derive(Deserialize, Debug, Default, PartialEq, Eq)]
-#[serde(rename_all = "camelCase")]
-enum Visibility {
-    #[default]
-    Visible,
-    Hidden,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-enum TextAlign {
-    Left,
-    Center,
-    Right,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-enum ContainerContent {
-    Table(Table),
-    Text(ContainerText),
-    Graph(Graph),
-    Model,
-    Object(Object),
-    Image(Image),
-    Tree,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Graph {
-    #[serde(rename = "@commandName")]
-    command_name: String,
-    data_path: Option<String>,
-    path: String,
-    csv_path: Option<String>,
-}
-
-impl Graph {
-    fn decode(&self, structure_member: &str) -> Item {
-        crate::output::Chart
-            .into_item()
-            .with_spv_info(
-                SpvInfo::new(structure_member).with_members(SpvMembers::Graph {
-                    data: self.data_path.clone(),
-                    xml: self.path.clone(),
-                    csv: self.csv_path.clone(),
-                }),
-            )
-    }
-}
-
-fn decode_image<R>(
-    archive: &mut ZipArchive<R>,
-    structure_member: &str,
-    command_name: &Option<String>,
-    image_name: &str,
-) -> Result<Item, Error>
-where
-    R: Read + Seek,
-{
-    let mut png = archive.by_name(image_name)?;
-    let image = ImageSurface::create_from_png(&mut png)?;
-    Ok(Details::Image(image)
-        .into_item()
-        .with_command_name(command_name.clone())
-        .with_spv_info(
-            SpvInfo::new(structure_member).with_members(SpvMembers::Image(image_name.into())),
-        ))
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Image {
-    #[serde(rename = "@commandName")]
-    command_name: Option<String>,
-    data_path: String,
-}
-
-impl Image {
-    fn decode<R>(&self, archive: &mut ZipArchive<R>, structure_member: &str) -> Result<Item, Error>
-    where
-        R: Read + Seek,
-    {
-        decode_image(
-            archive,
-            structure_member,
-            &self.command_name,
-            &self.data_path,
-        )
-    }
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Object {
-    #[serde(rename = "@commandName")]
-    command_name: Option<String>,
-    #[serde(rename = "@uri")]
-    uri: String,
-}
-
-impl Object {
-    fn decode<R>(&self, archive: &mut ZipArchive<R>, structure_member: &str) -> Result<Item, Error>
-    where
-        R: Read + Seek,
-    {
-        decode_image(archive, structure_member, &self.command_name, &self.uri)
-    }
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Table {
-    #[serde(rename = "@commandName")]
-    command_name: String,
-    #[serde(rename = "@subType")]
-    sub_type: String,
-    #[serde(rename = "@tableId")]
-    table_id: Option<i64>,
-    #[serde(rename = "@type")]
-    table_type: TableType,
-    properties: Option<TableProperties>,
-    table_structure: TableStructure,
-}
-
-impl Table {
-    fn decode<R>(&self, archive: &mut ZipArchive<R>, structure_member: &str) -> Result<Item, Error>
-    where
-        R: Read + Seek,
-    {
-        match &self.table_structure.path {
-            None => {
-                let member_name = &self.table_structure.data_path;
-                let mut light = archive.by_name(member_name)?;
-                let mut data = Vec::with_capacity(light.size() as usize);
-                light.read_to_end(&mut data)?;
-                let mut cursor = Cursor::new(data);
-                let table = LightTable::read(&mut cursor).map_err(|e| {
-                    e.with_message(format!(
-                        "While parsing {member_name:?} as light binary SPV member"
-                    ))
-                })?;
-                let pivot_table = table.decode()?;
-                Ok(pivot_table.into_item().with_spv_info(
-                    SpvInfo::new(structure_member)
-                        .with_members(SpvMembers::Light(self.table_structure.data_path.clone())),
-                ))
-            }
-            Some(xml_member_name) => {
-                let bin_member_name = &self.table_structure.data_path;
-                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 legacy binary SPV member"
-                    ))
-                })?;
-                let data = legacy_bin.decode();
-                drop(bin_member);
-
-                let member = BufReader::new(archive.by_name(&xml_member_name)?);
-                let visualization: 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,
-                    self.properties
-                        .as_ref()
-                        .map_or_else(Look::default, |properties| properties.clone().into()),
-                )?;
-
-                Ok(pivot_table.into_item().with_spv_info(
-                    SpvInfo::new(structure_member).with_members(SpvMembers::Legacy {
-                        xml: xml_member_name.clone(),
-                        binary: bin_member_name.clone(),
-                    }),
-                ))
-            }
-        }
-    }
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-enum TableType {
-    Table,
-    Note,
-    Warning,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct ContainerText {
-    #[serde(rename = "@type")]
-    text_type: TextType,
-    #[serde(rename = "@commandName")]
-    command_name: Option<String>,
-    html: String,
-}
-
-impl ContainerText {
-    fn decode(&self) -> Value {
-        html::Document::from_html(&self.html).into_value()
-    }
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-enum TextType {
-    Title,
-    Log,
-    Text,
-    #[serde(rename = "page-title")]
-    PageTitle,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct TableStructure {
-    /// The `.xml` member name, for legacy members only.
-    path: Option<String>,
-    /// The `.bin` member name.
-    data_path: String,
-    /// Rarely used, not understood.
-    csv_path: Option<String>,
-}
-
-#[cfg(test)]
-#[test]
-fn test_spv() {
-    let items = ReadOptions::new()
-        .open_file("/home/blp/pspp/rust/tests/utilities/regress.spv")
-        .unwrap()
-        .into_items();
-    for item in items {
-        println!("{item}");
-    }
-    todo!()
-}
diff --git a/rust/pspp/src/output/spv/css.rs b/rust/pspp/src/output/spv/css.rs
deleted file mode 100644 (file)
index 17fbe92..0000000
+++ /dev/null
@@ -1,377 +0,0 @@
-use std::{
-    borrow::Cow,
-    fmt::{Display, Write},
-    mem::discriminant,
-    ops::Not,
-};
-
-use itertools::Itertools;
-
-use crate::output::{
-    pivot::{FontStyle, HorzAlign},
-    spv::html::Style,
-};
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-enum Token<'a> {
-    Id(Cow<'a, str>),
-    LeftCurly,
-    RightCurly,
-    Colon,
-    Semicolon,
-    Error,
-}
-
-struct Lexer<'a>(&'a str);
-
-impl<'a> Iterator for Lexer<'a> {
-    type Item = Token<'a>;
-
-    fn next(&mut self) -> Option<Self::Item> {
-        let mut s = self.0;
-        loop {
-            s = s.trim_start();
-            if let Some(rest) = s.strip_prefix("<!--") {
-                s = rest;
-            } else if let Some(rest) = s.strip_prefix("-->") {
-                s = rest;
-            } else {
-                break;
-            }
-        }
-        let mut iter = s.chars();
-        let (c, mut rest) = (iter.next()?, iter.as_str());
-        let (token, rest) = match c {
-            '{' => (Token::LeftCurly, rest),
-            '}' => (Token::RightCurly, rest),
-            ':' => (Token::Colon, rest),
-            ';' => (Token::Semicolon, rest),
-            '\'' | '"' => {
-                let quote = c;
-                let mut s = String::new();
-                while let Some(c) = iter.next() {
-                    if c == quote {
-                        break;
-                    } else if c != '\\' {
-                        s.push(c);
-                    } else {
-                        let start = iter.as_str();
-                        match iter.next() {
-                            None => break,
-                            Some(a) if a.is_ascii_alphanumeric() => {
-                                let n = start
-                                    .chars()
-                                    .take_while(|c| c.is_ascii_alphanumeric())
-                                    .take(6)
-                                    .count();
-                                iter = start[n..].chars();
-                                if let Ok(code_point) = u32::from_str_radix(&start[..n], 16)
-                                    && let Ok(c) = char::try_from(code_point)
-                                {
-                                    s.push(c);
-                                }
-                            }
-                            Some('\n') => (),
-                            Some(other) => s.push(other),
-                        }
-                    }
-                }
-                (Token::Id(Cow::from(s)), iter.as_str())
-            }
-            _ => {
-                while !iter.as_str().starts_with("-->")
-                    && let Some(c) = iter.next()
-                    && !c.is_whitespace()
-                    && c != '{'
-                    && c != '}'
-                    && c != ':'
-                    && c != ';'
-                {
-                    rest = iter.as_str();
-                }
-                let id_len = s.len() - rest.len();
-                let (id, rest) = s.split_at(id_len);
-                (Token::Id(Cow::from(id)), rest)
-            }
-        };
-        self.0 = rest;
-        Some(token)
-    }
-}
-
-impl HorzAlign {
-    pub fn from_css(s: &str) -> Option<Self> {
-        let mut lexer = Lexer(s);
-        while let Some(token) = lexer.next() {
-            if let Token::Id(key) = token
-                && let Some(Token::Colon) = lexer.next()
-                && let Some(Token::Id(value)) = lexer.next()
-                && key.as_ref() == "text-align"
-                && let Ok(align) = value.parse()
-            {
-                return Some(align);
-            }
-        }
-        None
-    }
-}
-
-impl Style {
-    pub fn parse_css(styles: &mut Vec<Style>, s: &str) {
-        let mut lexer = Lexer(s);
-        while let Some(token) = lexer.next() {
-            if let Token::Id(key) = token
-                && let Some(Token::Colon) = lexer.next()
-                && let Some(Token::Id(value)) = lexer.next()
-                && let Some((style, add)) = match key.as_ref() {
-                    "color" => value.parse().ok().map(|color| (Style::Color(color), true)),
-                    "font-weight" => Some((Style::Bold, value == "bold")),
-                    "font-style" => Some((Style::Italic, value == "italic")),
-                    "text-decoration" => Some((Style::Underline, value == "underline")),
-                    "font-family" => Some((Style::Face(value.into()), true)),
-                    "font-size" => value
-                        .parse::<i32>()
-                        .ok()
-                        .map(|size| (Style::Size(size as f64 * 0.75), true)),
-                    _ => None,
-                }
-            {
-                // Remove from `styles` any style of the same kind as `style`.
-                styles.retain(|s| discriminant(s) != discriminant(&style));
-                if add {
-                    styles.push(style);
-                }
-            }
-        }
-    }
-}
-
-impl FontStyle {
-    pub fn parse_css(&mut self, s: &str) {
-        let mut lexer = Lexer(s);
-        while let Some(token) = lexer.next() {
-            if let Token::Id(key) = token
-                && let Some(Token::Colon) = lexer.next()
-                && let Some(Token::Id(value)) = lexer.next()
-            {
-                match key.as_ref() {
-                    "color" => {
-                        if let Ok(color) = value.parse() {
-                            self.fg = color;
-                        }
-                    }
-                    "font-weight" => self.bold = value == "bold",
-                    "font-style" => self.italic = value == "italic",
-                    "text-decoration" => self.underline = value == "underline",
-                    "font-family" => self.font = value.into(),
-                    "font-size" => {
-                        if let Ok(size) = value.parse::<i32>() {
-                            self.size = (size as i64 * 3 / 4) as i32;
-                        }
-                    }
-                    _ => (),
-                }
-            }
-        }
-    }
-
-    pub fn from_css(s: &str) -> Self {
-        let mut style = FontStyle::default();
-        style.parse_css(s);
-        style
-    }
-
-    pub fn to_css(&self, base: &FontStyle) -> Option<String> {
-        let mut settings = Vec::new();
-        if self.font != base.font {
-            if is_css_ident(&self.font) {
-                settings.push(format!("font-family: {}", &self.font));
-            } else {
-                settings.push(format!("font-family: {}", CssString(&self.font)));
-            }
-        }
-        if self.bold != base.bold {
-            settings.push(format!(
-                "font-weight: {}",
-                if self.bold { "bold" } else { "normal" }
-            ));
-        }
-        if self.italic != base.italic {
-            settings.push(format!(
-                "font-style: {}",
-                if self.bold { "italic" } else { "normal" }
-            ));
-        }
-        if self.underline != base.underline {
-            settings.push(format!(
-                "text-decoration: {}",
-                if self.bold { "underline" } else { "none" }
-            ));
-        }
-        if self.size != base.size {
-            settings.push(format!("font-size: {}", self.size as i64 * 4 / 3));
-        }
-        if self.fg != base.fg {
-            settings.push(format!("color: {}", self.fg.display_css()));
-        }
-        settings
-            .is_empty()
-            .not()
-            .then(|| format!("<!-- p {{ {} }} -->", settings.into_iter().join("; ")))
-    }
-}
-
-fn is_css_ident(s: &str) -> bool {
-    fn is_nmstart(c: char) -> bool {
-        c.is_ascii_alphabetic() || c == '_'
-    }
-    s.chars().next().is_some_and(is_nmstart) && s.chars().all(|c| is_nmstart(c) || c as u32 > 159)
-}
-
-struct CssString<'a>(&'a str);
-
-impl<'a> Display for CssString<'a> {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        let quote = if self.0.contains('"') && !self.0.contains('\'') {
-            '\''
-        } else {
-            '"'
-        };
-        f.write_char(quote)?;
-        for c in self.0.chars() {
-            match c {
-                _ if c == quote || c == '\\' => {
-                    f.write_char('\\')?;
-                    f.write_char(c)?;
-                }
-                '\n' => f.write_str("\\00000a")?,
-                c => f.write_char(c)?,
-            }
-        }
-        f.write_char(quote)
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use std::borrow::Cow;
-
-    use crate::output::{
-        pivot::{Color, FontStyle, HorzAlign},
-        spv::css::{Lexer, Token},
-    };
-
-    #[test]
-    fn css_horz_align() {
-        assert_eq!(
-            HorzAlign::from_css("text-align: left"),
-            Some(HorzAlign::Left)
-        );
-        assert_eq!(
-            HorzAlign::from_css("margin-top: 0; text-align:center"),
-            Some(HorzAlign::Center)
-        );
-        assert_eq!(
-            HorzAlign::from_css("text-align: Right; margin-top:0"),
-            Some(HorzAlign::Right)
-        );
-        assert_eq!(HorzAlign::from_css("text-align: other"), None);
-        assert_eq!(HorzAlign::from_css("margin-top: 0"), None);
-    }
-
-    #[test]
-    fn css_strings() {
-        #[track_caller]
-        fn test_string(css: &str, value: &str) {
-            let mut lexer = Lexer(css);
-            assert_eq!(lexer.next(), Some(Token::Id(Cow::from(value))));
-            assert_eq!(lexer.next(), None);
-        }
-
-        test_string(r#""abc""#, "abc");
-        test_string(r#""a\"'\'bc""#, "a\"''bc");
-        test_string(r#""a\22 bc""#, "a\" bc");
-        test_string(r#""a\000022bc""#, "a\"bc");
-        test_string(r#""a'bc""#, "a'bc");
-        test_string(
-            r#""\\\
-xyzzy""#,
-            "\\xyzzy",
-        );
-
-        test_string(r#"'abc'"#, "abc");
-        test_string(r#"'a"\"\'bc'"#, "a\"\"'bc");
-        test_string(r#"'a\22 bc'"#, "a\" bc");
-        test_string(r#"'a\000022bc'"#, "a\"bc");
-        test_string(r#"'a\'bc'"#, "a'bc");
-        test_string(
-            r#"'a\'bc\
-xyz'"#,
-            "a'bcxyz",
-        );
-        test_string(r#"'\\'"#, "\\");
-    }
-
-    #[test]
-    fn style_from_css() {
-        assert_eq!(FontStyle::from_css(""), FontStyle::default());
-        assert_eq!(
-            FontStyle::from_css(r#"p{color:ff0000}"#),
-            FontStyle::default().with_fg(Color::RED)
-        );
-        assert_eq!(
-            FontStyle::from_css("p {font-weight: bold; text-decoration: underline}"),
-            FontStyle::default().with_bold(true).with_underline(true)
-        );
-        assert_eq!(
-            FontStyle::from_css("p {font-family: Monospace}"),
-            FontStyle::default().with_font("Monospace")
-        );
-        assert_eq!(
-            FontStyle::from_css("p {font-size: 24}"),
-            FontStyle::default().with_size(18)
-        );
-        assert_eq!(
-            FontStyle::from_css(
-                "<!--color: red; font-weight: bold; font-style: italic; text-decoration: underline; font-family: Serif-->"
-            ),
-            FontStyle::default()
-                .with_fg(Color::RED)
-                .with_bold(true)
-                .with_italic(true)
-                .with_underline(true)
-                .with_font("Serif")
-        );
-    }
-
-    #[test]
-    fn style_to_css() {
-        let base = FontStyle::default();
-        assert_eq!(base.to_css(&base), None);
-        assert_eq!(
-            FontStyle::default().with_size(18).to_css(&base),
-            Some("<!-- p { font-size: 24 } -->".into())
-        );
-        assert_eq!(
-            FontStyle::default()
-                .with_bold(true)
-                .with_underline(true)
-                .to_css(&base),
-            Some("<!-- p { font-weight: bold; text-decoration: underline } -->".into())
-        );
-        assert_eq!(
-            FontStyle::default().with_fg(Color::RED).to_css(&base),
-            Some("<!-- p { color: #ff0000 } -->".into())
-        );
-        assert_eq!(
-            FontStyle::default().with_font("Monospace").to_css(&base),
-            Some("<!-- p { font-family: Monospace } -->".into())
-        );
-        assert_eq!(
-            FontStyle::default()
-                .with_font("Times New Roman")
-                .to_css(&base),
-            Some(r#"<!-- p { font-family: "Times New Roman" } -->"#.into())
-        );
-    }
-}
diff --git a/rust/pspp/src/output/spv/html.rs b/rust/pspp/src/output/spv/html.rs
deleted file mode 100644 (file)
index 4db9891..0000000
+++ /dev/null
@@ -1,925 +0,0 @@
-#![warn(dead_code)]
-use std::{
-    borrow::{Borrow, Cow},
-    fmt::{Display, Write as _},
-    io::{Cursor, Write},
-    mem::{discriminant, take},
-    str::FromStr,
-};
-
-use hashbrown::HashMap;
-use html_parser::{Dom, Element, Node};
-use pango::{AttrColor, AttrInt, AttrList, AttrSize, AttrString, IsAttribute};
-use quick_xml::{
-    Writer as XmlWriter,
-    escape::unescape,
-    events::{BytesText, Event},
-};
-use serde::{Deserialize, Deserializer, Serialize, ser::SerializeMap};
-
-use crate::output::pivot::{CellStyle, Color, FontStyle, HorzAlign, Value};
-
-fn lowercase<'a>(s: &'a str) -> Cow<'a, str> {
-    if s.chars().any(|c| c.is_ascii_uppercase()) {
-        Cow::from(s.to_ascii_lowercase())
-    } else {
-        Cow::from(s)
-    }
-}
-
-#[derive(Clone, Debug, PartialEq)]
-pub enum Markup {
-    Seq(Vec<Markup>),
-    Text(String),
-    Variable(Variable),
-    Style { style: Style, child: Box<Markup> },
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize)]
-pub enum Variable {
-    Date,
-    Time,
-    Head(u8),
-    PageTitle,
-    Page,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)]
-#[error("Unknown variable")]
-pub struct UnknownVariable;
-
-impl FromStr for Variable {
-    type Err = UnknownVariable;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        match s {
-            "Date" => Ok(Self::Date),
-            "Time" => Ok(Self::Time),
-            "PageTitle" => Ok(Self::PageTitle),
-            "Page" => Ok(Self::Page),
-            _ => {
-                if let Some(suffix) = s.strip_prefix("Head")
-                    && let Ok(number) = suffix.parse()
-                    && number >= 1
-                {
-                    Ok(Self::Head(number))
-                } else {
-                    Err(UnknownVariable)
-                }
-            }
-        }
-    }
-}
-
-impl Variable {
-    fn as_str(&self) -> Cow<'static, str> {
-        match self {
-            Variable::Date => Cow::from("Date"),
-            Variable::Time => Cow::from("Time"),
-            Variable::Head(index) => Cow::from(format!("Head{index}")),
-            Variable::PageTitle => Cow::from("PageTitle"),
-            Variable::Page => Cow::from("Page"),
-        }
-    }
-}
-
-impl Display for Variable {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "{}", self.as_str())
-    }
-}
-
-impl Default for Markup {
-    fn default() -> Self {
-        Self::Seq(Vec::new())
-    }
-}
-
-impl Serialize for Markup {
-    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-    where
-        S: serde::Serializer,
-    {
-        match self {
-            Markup::Seq(inner) => serializer.collect_seq(inner),
-            Markup::Text(string) => serializer.serialize_str(string.as_str()),
-            Markup::Variable(name) => serializer.serialize_newtype_struct("Variable", name),
-            Markup::Style { style, child } => {
-                let (mut style, mut child) = (style, child);
-                let mut styles = HashMap::new();
-                loop {
-                    styles.insert(discriminant(style), style);
-                    match &**child {
-                        Markup::Style {
-                            style: inner,
-                            child: inner_child,
-                        } => {
-                            style = inner;
-                            child = inner_child;
-                        }
-                        _ => break,
-                    }
-                }
-                let mut map = serializer.serialize_map(Some(styles.len() + 1))?;
-                for style in styles.into_values() {
-                    match style {
-                        Style::Bold => map.serialize_entry("bool", &true),
-                        Style::Italic => map.serialize_entry("italic", &true),
-                        Style::Underline => map.serialize_entry("underline", &true),
-                        Style::Strike => map.serialize_entry("strike", &true),
-                        Style::Emphasis => map.serialize_entry("em", &true),
-                        Style::Strong => map.serialize_entry("strong", &true),
-                        Style::Face(name) => map.serialize_entry("font", name),
-                        Style::Color(color) => map.serialize_entry("color", color),
-                        Style::Size(size) => map.serialize_entry("size", size),
-                    }?;
-                }
-                map.serialize_entry("content", child)?;
-                map.end()
-            }
-        }
-    }
-}
-
-impl Display for Markup {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        fn inner(this: &Markup, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-            match this {
-                Markup::Seq(seq) => {
-                    for markup in seq {
-                        inner(markup, f)?;
-                    }
-                    Ok(())
-                }
-                Markup::Text(string) => f.write_str(string.as_str()),
-                Markup::Variable(name) => write!(f, "&[{name}]"),
-                Markup::Style { child, .. } => inner(child, f),
-            }
-        }
-        inner(self, f)
-    }
-}
-
-impl Markup {
-    fn is_empty(&self) -> bool {
-        match self {
-            Markup::Seq(seq) => seq.is_empty(),
-            _ => false,
-        }
-    }
-    fn is_style(&self) -> bool {
-        matches!(self, Markup::Style { .. })
-    }
-    fn into_style(self) -> Option<(Style, Markup)> {
-        match self {
-            Markup::Style { style, child } => Some((style, *child)),
-            _ => None,
-        }
-    }
-    fn is_text(&self) -> bool {
-        matches!(self, Markup::Text(_))
-    }
-    fn as_text(&self) -> Option<&str> {
-        match self {
-            Markup::Text(text) => Some(text.as_str()),
-            _ => None,
-        }
-    }
-    fn into_text(self) -> Option<String> {
-        match self {
-            Markup::Text(text) => Some(text),
-            _ => None,
-        }
-    }
-    fn write_html<X>(&self, writer: &mut XmlWriter<X>) -> std::io::Result<()>
-    where
-        X: Write,
-    {
-        match self {
-            Markup::Seq(children) => {
-                for child in children {
-                    child.write_html(writer)?;
-                }
-            }
-            Markup::Text(text) => writer.write_event(Event::Text(BytesText::new(text.as_str())))?,
-            Markup::Variable(name) => {
-                writer.write_event(Event::Text(BytesText::new(&format!("&[{name}]"))))?
-            }
-            Markup::Style { style, child } => {
-                match style {
-                    Style::Bold => writer.create_element("b"),
-                    Style::Italic => writer.create_element("i"),
-                    Style::Underline => writer.create_element("u"),
-                    Style::Strike => writer.create_element("strike"),
-                    Style::Emphasis => writer.create_element("em"),
-                    Style::Strong => writer.create_element("strong"),
-                    Style::Face(face) => writer
-                        .create_element("font")
-                        .with_attribute(("face", face.as_str())),
-                    Style::Color(color) => writer
-                        .create_element("font")
-                        .with_attribute(("color", color.display_css().to_string().as_str())),
-                    Style::Size(points) => writer
-                        .create_element("font")
-                        .with_attribute(("size", format!("{points}pt").as_str())),
-                }
-                .write_inner_content(|w| child.write_html(w))?;
-            }
-        }
-        Ok(())
-    }
-
-    pub fn to_html(&self) -> String {
-        let mut writer = XmlWriter::new(Cursor::new(Vec::new()));
-        writer
-            .create_element("html")
-            .write_inner_content(|w| self.write_html(w))
-            .unwrap();
-        String::from_utf8(writer.into_inner().into_inner()).unwrap()
-    }
-
-    pub fn to_pango<'a, F>(&self, substitutions: F) -> (String, AttrList)
-    where
-        F: Fn(Variable) -> Option<Cow<'a, str>>,
-    {
-        let mut s = String::new();
-        let mut attrs = AttrList::new();
-        self.to_pango_inner(&substitutions, &mut s, &mut attrs);
-        (s, attrs)
-    }
-
-    fn to_pango_inner<'a, F>(&self, substitutions: &F, s: &mut String, attrs: &mut AttrList)
-    where
-        F: Fn(Variable) -> Option<Cow<'a, str>>,
-    {
-        match self {
-            Markup::Seq(seq) => {
-                for child in seq {
-                    child.to_pango_inner(substitutions, s, attrs);
-                }
-            }
-            Markup::Text(string) => s.push_str(&string),
-            Markup::Variable(variable) => match substitutions(*variable) {
-                Some(value) => s.push_str(&*value),
-                None => write!(s, "&[{variable}]").unwrap(),
-            },
-            Markup::Style { style, child } => {
-                let start_index = s.len();
-                child.to_pango_inner(substitutions, s, attrs);
-                let end_index = s.len();
-
-                let mut attr = match style {
-                    Style::Bold | Style::Strong => {
-                        AttrInt::new_weight(pango::Weight::Bold).upcast()
-                    }
-                    Style::Italic | Style::Emphasis => {
-                        AttrInt::new_style(pango::Style::Italic).upcast()
-                    }
-                    Style::Underline => AttrInt::new_underline(pango::Underline::Single).upcast(),
-                    Style::Strike => AttrInt::new_strikethrough(true).upcast(),
-                    Style::Face(face) => AttrString::new_family(&face).upcast(),
-                    Style::Color(color) => {
-                        let (r, g, b) = color.into_rgb16();
-                        AttrColor::new_foreground(r, g, b).upcast()
-                    }
-                    Style::Size(points) => AttrSize::new((points * 1024.0) as i32).upcast(),
-                };
-                attr.set_start_index(start_index as u32);
-                attr.set_end_index(end_index as u32);
-                attrs.insert(attr);
-            }
-        }
-    }
-
-    fn parse_variables(&self) -> Option<Vec<Markup>> {
-        let Some(mut s) = self.as_text() else {
-            return None;
-        };
-        let mut results = Vec::new();
-        let mut offset = 0;
-        while let Some(start) = s[offset..].find("&[").map(|pos| pos + offset)
-            && let Some(end) = s[start..].find("]").map(|pos| pos + start)
-        {
-            if let Ok(variable) = Variable::from_str(&s[start + 2..end]) {
-                if start > 0 {
-                    results.push(Markup::Text(s[..start].into()));
-                }
-                results.push(Markup::Variable(variable));
-                s = &s[end + 1..];
-                offset = 0;
-            } else {
-                offset = end + 1;
-            }
-        }
-        if results.is_empty() {
-            None
-        } else {
-            if !s.is_empty() {
-                results.push(Markup::Text(s.into()));
-            }
-            Some(results)
-        }
-    }
-}
-
-#[derive(Clone, Debug, PartialEq, Serialize)]
-pub struct Paragraph {
-    pub markup: Markup,
-    pub horz_align: HorzAlign,
-}
-
-impl Default for Paragraph {
-    fn default() -> Self {
-        Self {
-            markup: Markup::default(),
-            horz_align: HorzAlign::Left,
-        }
-    }
-}
-
-impl Paragraph {
-    fn new(mut markup: Markup, horz_align: HorzAlign, css: &[Style]) -> Self {
-        for style in css {
-            apply_style(&mut markup, style.clone());
-        }
-        Self { markup, horz_align }
-    }
-
-    fn into_value(self) -> Value {
-        let mut font_style = FontStyle::default().with_size(10);
-        let cell_style = CellStyle::default().with_horz_align(Some(self.horz_align));
-        let mut markup = self.markup;
-        let mut strike = false;
-        while markup.is_style() {
-            let (style, child) = markup.into_style().unwrap();
-            match style {
-                Style::Bold => font_style.bold = true,
-                Style::Italic => font_style.italic = true,
-                Style::Underline => font_style.underline = true,
-                Style::Strike => strike = true,
-                Style::Emphasis => font_style.italic = true,
-                Style::Strong => font_style.bold = true,
-                Style::Face(face) => font_style.font = face,
-                Style::Color(color) => font_style.fg = color,
-                Style::Size(points) => font_style.size = points as i32,
-            };
-            markup = child;
-        }
-        if strike {
-            apply_style(&mut markup, Style::Strike);
-        }
-        if markup.is_text() {
-            Value::new_user_text(markup.into_text().unwrap())
-        } else {
-            Value::new_markup(markup)
-        }
-        .with_font_style(font_style)
-        .with_cell_style(cell_style)
-    }
-}
-
-#[derive(Clone, Debug, Default, PartialEq)]
-pub struct Document(pub Vec<Paragraph>);
-
-impl<'de> Deserialize<'de> for Document {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        Ok(Document::from_html(&String::deserialize(deserializer)?))
-    }
-}
-
-impl Serialize for Document {
-    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-    where
-        S: serde::Serializer,
-    {
-        self.to_html().serialize(serializer)
-    }
-}
-
-impl Document {
-    pub fn is_empty(&self) -> bool {
-        self.0.is_empty()
-    }
-
-    pub fn from_html(input: &str) -> Self {
-        match Dom::parse(&format!("<!doctype html>{input}")) {
-            Ok(dom) => Self(parse_dom(&dom)),
-            Err(_) if !input.is_empty() => Self(vec![Paragraph {
-                markup: Markup::Text(input.into()),
-                horz_align: HorzAlign::Left,
-            }]),
-            Err(_) => Self::default(),
-        }
-    }
-
-    pub fn into_value(self) -> Value {
-        self.0.into_iter().next().unwrap_or_default().into_value()
-    }
-
-    pub fn to_html(&self) -> String {
-        let mut writer = XmlWriter::new(Cursor::new(Vec::new()));
-        writer
-            .create_element("html")
-            .write_inner_content(|w| {
-                for paragraph in &self.0 {
-                    w.create_element("p")
-                        .with_attribute(("align", paragraph.horz_align.as_str().unwrap()))
-                        .write_inner_content(|w| paragraph.markup.write_html(w))?;
-                }
-                Ok(())
-            })
-            .unwrap();
-
-        // Return the result with `<html>` and `</html>` stripped off.
-        str::from_utf8(&writer.into_inner().into_inner())
-            .unwrap()
-            .strip_prefix("<html>")
-            .unwrap()
-            .strip_suffix("</html>")
-            .unwrap()
-            .into()
-    }
-
-    pub fn to_values(&self) -> Vec<Value> {
-        self.0
-            .iter()
-            .map(|paragraph| paragraph.clone().into_value())
-            .collect()
-    }
-}
-
-#[derive(Clone, Debug, PartialEq)]
-pub enum Style {
-    Bold,
-    Italic,
-    Underline,
-    Strike,
-    Emphasis,
-    Strong,
-    Face(String),
-    Color(Color),
-    Size(f64),
-}
-
-fn node_as_element<'a>(node: &'a Node, name: &str) -> Option<&'a Element> {
-    if let Node::Element(element) = node
-        && element.name.eq_ignore_ascii_case(name)
-    {
-        Some(element)
-    } else {
-        None
-    }
-}
-
-fn node_is_element(node: &Node, name: &str) -> bool {
-    node_as_element(node, name).is_some()
-}
-
-/// Returns the horizontal alignment for the `<p>` element in `p`.
-fn horz_align_from_p(p: &Element) -> Option<HorzAlign> {
-    if let Some(Some(s)) = p.attributes.get("align")
-        && let Ok(align) = HorzAlign::from_str(s)
-    {
-        Some(align)
-    } else if let Some(Some(s)) = p.attributes.get("style")
-        && let Some(align) = HorzAlign::from_css(s)
-    {
-        Some(align)
-    } else {
-        None
-    }
-}
-
-fn apply_style(markup: &mut Markup, style: Style) {
-    let child = take(markup);
-    *markup = Markup::Style {
-        style,
-        child: Box::new(child),
-    };
-}
-
-pub fn parse_dom(dom: &Dom) -> Vec<Paragraph> {
-    // Get the top-level elements, descending into an `html` element if
-    // there is one.
-    let roots = if dom.children.len() == 1
-        && let Some(first) = dom.children.first()
-        && let Some(html) = node_as_element(first, "html")
-    {
-        &html.children
-    } else {
-        &dom.children
-    };
-
-    // If there's a `head` element, parse it for CSS and then skip past it.
-    let mut css = Vec::new();
-    let mut default_horz_align = HorzAlign::Left;
-    let roots = if let Some((first, rest)) = roots.split_first()
-        && let Some(head) = node_as_element(first, "head")
-    {
-        if let Some(style) = find_element(&head.children, "style") {
-            let mut text = String::new();
-            get_element_text(style, &mut text);
-            Style::parse_css(&mut css, &text);
-            if let Some(horz_align) = HorzAlign::from_css(&text) {
-                default_horz_align = horz_align;
-            }
-        }
-        rest
-    } else {
-        roots
-    };
-
-    // If only a `body` element is left, descend into it.
-    let body = if roots.len() == 1
-        && let Some(first) = roots.first()
-        && let Some(body) = node_as_element(first, "body")
-    {
-        &body.children
-    } else {
-        roots
-    };
-
-    let mut paragraphs = Vec::new();
-
-    let mut start = 0;
-    while start < body.len() {
-        let (end, align) = if let Some(p) = node_as_element(&body[start], "p") {
-            (
-                start + 1,
-                horz_align_from_p(p).unwrap_or(default_horz_align),
-            )
-        } else {
-            let mut end = start + 1;
-            while end < body.len() && !node_is_element(&body[end], "p") {
-                end += 1;
-            }
-            (end, default_horz_align)
-        };
-        paragraphs.push(Paragraph::new(parse_nodes(&body[start..end]), align, &css));
-        start = end;
-    }
-
-    paragraphs
-}
-
-fn parse_nodes(nodes: &[Node]) -> Markup {
-    // Appends `markup` to `dst`, merging text at the end of `dst` with text
-    // in `markup`.
-    fn add_markup(dst: &mut Vec<Markup>, markup: Markup) {
-        if let Markup::Text(suffix) = &markup
-            && let Some(Markup::Text(last)) = dst.last_mut()
-        {
-            last.push_str(&suffix);
-        } else {
-            dst.push(markup);
-        }
-
-        if let Some(mut expansion) = dst.last().unwrap().parse_variables() {
-            dst.pop();
-            dst.append(&mut expansion);
-        }
-    }
-
-    let mut retval = Vec::new();
-    for (i, node) in nodes.iter().enumerate() {
-        match node {
-            Node::Comment(_) => (),
-            Node::Text(text) => {
-                let text = if i == 0 {
-                    text.trim_start()
-                } else {
-                    text.as_str()
-                };
-                let text = if i == nodes.len() - 1 {
-                    text.trim_end()
-                } else {
-                    text
-                };
-                add_markup(
-                    &mut retval,
-                    Markup::Text(unescape(&text).unwrap_or(Cow::from(text)).into_owned()),
-                );
-            }
-            Node::Element(br) if br.name.eq_ignore_ascii_case("br") => {
-                add_markup(&mut retval, Markup::Text('\n'.into()));
-            }
-            Node::Element(element) => {
-                let mut inner = parse_nodes(&element.children);
-                if inner.is_empty() {
-                    continue;
-                }
-
-                let style = match lowercase(&element.name).borrow() {
-                    "b" => Some(Style::Bold),
-                    "i" => Some(Style::Italic),
-                    "u" => Some(Style::Underline),
-                    "s" | "strike" => Some(Style::Strike),
-                    "strong" => Some(Style::Strong),
-                    "em" => Some(Style::Emphasis),
-                    "font" => {
-                        if let Some(Some(face)) = element.attributes.get("face") {
-                            apply_style(&mut inner, Style::Face(face.clone()));
-                        }
-                        if let Some(Some(color)) = element.attributes.get("color")
-                            && let Ok(color) = Color::from_str(&color)
-                        {
-                            apply_style(&mut inner, Style::Color(color));
-                        }
-                        if let Some(Some(html_size)) = element.attributes.get("size")
-                            && let Ok(html_size) = usize::from_str(&html_size)
-                            && let Some(index) = html_size.checked_sub(1)
-                            && let Some(points) =
-                                [6.0, 7.5, 9.0, 10.5, 13.5, 18.0, 27.0].get(index).copied()
-                        {
-                            apply_style(&mut inner, Style::Size(points));
-                        }
-                        None
-                    }
-                    _ => None,
-                };
-                match style {
-                    None => match inner {
-                        Markup::Seq(seq) => {
-                            for markup in seq {
-                                add_markup(&mut retval, markup);
-                            }
-                        }
-                        _ => add_markup(&mut retval, inner),
-                    },
-                    Some(style) => retval.push(Markup::Style {
-                        style,
-                        child: Box::new(inner),
-                    }),
-                }
-            }
-        }
-    }
-    if retval.len() == 1 {
-        retval.into_iter().next().unwrap()
-    } else {
-        Markup::Seq(retval)
-    }
-}
-
-fn find_element<'a>(elements: &'a [Node], name: &str) -> Option<&'a Element> {
-    for element in elements {
-        if let Node::Element(element) = element
-            && element.name == name
-        {
-            return Some(element);
-        }
-    }
-    None
-}
-
-fn parse_entity(s: &str) -> (char, &str) {
-    static ENTITIES: [(&str, char); 6] = [
-        ("amp;", '&'),
-        ("lt;", '<'),
-        ("gt;", '>'),
-        ("apos;", '\''),
-        ("quot;", '"'),
-        ("nbsp;", '\u{00a0}'),
-    ];
-    for (name, ch) in ENTITIES {
-        if let Some(rest) = s.strip_prefix(name) {
-            return (ch, rest);
-        }
-    }
-    ('&', s)
-}
-
-fn get_node_text(node: &Node, text: &mut String) {
-    match node {
-        Node::Text(string) => {
-            let mut s = string.as_str();
-            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;
-                }
-                let ch;
-                (ch, s) = parse_entity(&s[1..]);
-                text.push(ch);
-            }
-        }
-        Node::Element(element) => get_element_text(element, text),
-        Node::Comment(_) => (),
-    }
-}
-
-fn get_element_text(element: &Element, text: &mut String) {
-    for child in &element.children {
-        get_node_text(child, text);
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use std::{borrow::Cow, str::FromStr};
-
-    use crate::output::spv::html::{self, Document, Markup, Variable};
-
-    #[test]
-    fn variable() {
-        assert_eq!(Variable::from_str("Head1").unwrap(), Variable::Head(1));
-        assert_eq!(Variable::from_str("Page").unwrap(), Variable::Page);
-        assert_eq!(Variable::from_str("Date").unwrap(), Variable::Date);
-        assert_eq!(Variable::Head(1).to_string(), "Head1");
-        assert_eq!(Variable::Page.to_string(), "Page");
-        assert_eq!(Variable::Date.to_string(), "Date");
-    }
-
-    #[test]
-    fn parse_variables() {
-        assert_eq!(Markup::Text("asdf".into()).parse_variables(), None);
-        assert_eq!(Markup::Text("&[asdf]".into()).parse_variables(), None);
-        assert_eq!(
-            Markup::Text("&[Page]".into()).parse_variables(),
-            Some(vec![Markup::Variable(Variable::Page)])
-        );
-        assert_eq!(
-            Markup::Text("xyzzy &[Invalid] &[Page] &[Invalid2] quux".into()).parse_variables(),
-            Some(vec![
-                Markup::Text("xyzzy &[Invalid] ".into()),
-                Markup::Variable(Variable::Page),
-                Markup::Text(" &[Invalid2] quux".into()),
-            ])
-        );
-    }
-
-    /// Example from the documentation.
-    #[test]
-    fn example1() {
-        let text = r##"<xml>&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
-  &lt;head>
-
-  &lt;/head>
-  &lt;body>
-    &lt;p>
-      plain&amp;#160;&lt;font color="#000000" size="3" face="Monospaced">&lt;b>bold&lt;/b>&lt;/font>&amp;#160;&lt;font color="#000000" size="3" face="Monospaced">&lt;i>italic&lt;/i>&amp;#160;&lt;strike>strikeout&lt;/strike>&lt;/font>
-    &lt;/p>
-  &lt;/body>
-&lt;/html>
-</xml>"##;
-        let content = quick_xml::de::from_str::<String>(text).unwrap();
-        assert_eq!(
-            Document::from_html(&content).to_html(),
-            r##"<p align="left">plain <font size="9pt"><font color="#000000"><font face="Monospaced"><b>bold</b></font></font></font> <font size="9pt"><font color="#000000"><font face="Monospaced"><i>italic</i> <strike>strikeout</strike></font></font></font></p>"##
-        );
-    }
-
-    /// Another example from the documentation.
-    #[test]
-    fn example2() {
-        let text = r##"<xml>&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
-  &lt;head>
-
-  &lt;/head>
-  &lt;body>
-    &lt;p>left&lt;/p>
-    &lt;p align="center">&lt;font color="#000000" size="5" face="Monospaced">center&amp;#160;large&lt;/font>&lt;/p>
-    &lt;p align="right">&lt;font color="#000000" size="3" face="Monospaced">&lt;b>&lt;i>right&lt;/i>&lt;/b>&lt;/font>&lt;/p>
-  &lt;/body>
-&lt;/html></xml>
-"##;
-        let content = quick_xml::de::from_str::<String>(text).unwrap();
-        assert_eq!(
-            Document::from_html(&content).to_html(),
-            r##"<p align="left">left</p><p align="center"><font size="13.5pt"><font color="#000000"><font face="Monospaced">center large</font></font></font></p><p align="right"><font size="9pt"><font color="#000000"><font face="Monospaced"><b><i>right</i></b></font></font></font></p>"##
-        );
-    }
-
-    /*
-    #[test]
-    fn value() {
-        let value = parse_value(
-            r#"<b>bold</b><br><i>italic</i><BR><b><i>bold italic</i></b><br><font color="red" face="Serif">red serif</font><br><font size="7">big</font><br>"#,
-        );
-        assert_eq!(
-            value,
-            Value::new_markup(
-                r##"<b>bold</b>
-<i>italic</i>
-<b><i>bold italic</i></b>
-<span face="Serif" color="#ff0000">red serif</span>
-<span size="20480">big</span>
-"##
-            )
-            .with_font_style(FontStyle::default().with_size(10))
-        );
-    }*/
-
-    /// From the corpus (also included in the documentation).
-    #[test]
-    fn header1() {
-        let text = r##"<xml>&lt;html xmlns="http://xml.spss.com/spss/viewer/viewer-tree">
-  &lt;head>
-
-  &lt;/head>
-  &lt;body>
-    &lt;p style="text-align:center; margin-top: 0">
-      &amp;[PageTitle]
-    &lt;/p>
-  &lt;/body>
-&lt;/html></xml>"##;
-        let content = quick_xml::de::from_str::<String>(text).unwrap();
-        assert_eq!(
-            Document::from_html(&content).to_html(),
-            r##"<p align="center">&amp;[PageTitle]</p>"##
-        );
-    }
-
-    /// From the corpus (also included in the documentation).
-    #[test]
-    fn footer1() {
-        let text = r##"<xml>&lt;html xmlns="http://xml.spss.com/spss/viewer/viewer-tree">
-  &lt;head>
-
-  &lt;/head>
-  &lt;body>
-    &lt;p style="text-align:right; margin-top: 0">
-      Page &amp;[Page]
-    &lt;/p>
-  &lt;/body>
-&lt;/html></xml>"##;
-        let content = quick_xml::de::from_str::<String>(text).unwrap();
-        assert_eq!(
-            Document::from_html(&content).to_html(),
-            r##"<p align="right">Page &amp;[Page]</p>"##
-        );
-    }
-
-    /// From the corpus (also included in the documentation).
-    #[test]
-    fn header2() {
-        let text = r##"<xml>&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
-  &lt;head>
-          &lt;style type="text/css">
-                  p { font-family: sans-serif;
-                       font-size: 10pt; text-align: center;
-                       font-weight: normal;
-                       color: #000000;
-                       }
-          &lt;/style>
-  &lt;/head>
-  &lt;body>
-          &lt;p>&amp;amp;[PageTitle]&lt;/p>
-  &lt;/body>
-&lt;/html></xml>"##;
-        let content = quick_xml::de::from_str::<String>(text).unwrap();
-        let document = Document::from_html(&content);
-        assert_eq!(
-            document.to_html(),
-            r##"<p align="center"><font color="#000000"><font face="sans-serif">&amp;[PageTitle]</font></font></p>"##
-        );
-        assert_eq!(
-            document.0[0]
-                .markup
-                .to_pango(
-                    |name| (name == html::Variable::PageTitle).then_some(Cow::from("The title"))
-                )
-                .0,
-            "The title"
-        );
-    }
-
-    /// From the corpus (also included in the documentation).
-    #[test]
-    fn footer2() {
-        let text = r##"<xml>&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
-  &lt;head>
-          &lt;style type="text/css">
-                  p { font-family: sans-serif;
-                       font-size: 10pt; text-align: right;
-                       font-weight: normal;
-                       color: #000000;
-                       }
-          &lt;/style>
-  &lt;/head>
-  &lt;body>
-          &lt;p>Page &amp;amp;[Page]&lt;/p>
-  &lt;/body>
-&lt;/html>
-</xml>"##;
-        let content = quick_xml::de::from_str::<String>(text).unwrap();
-        let html = Document::from_html(&content);
-        assert_eq!(
-            html.to_html(),
-            r##"<p align="right"><font color="#000000"><font face="sans-serif">Page &amp;[Page]</font></font></p>"##
-        );
-    }
-
-    /// Checks that the `escape-html` feature is enabled in [quick_xml], since
-    /// we need that to resolve `&nbsp;` and other HTML entities.
-    #[test]
-    fn html_escapes() {
-        let html = Document::from_html("&nbsp;");
-        assert_eq!(html.to_html(), "<p align=\"left\">\u{a0}</p>")
-    }
-}
diff --git a/rust/pspp/src/output/spv/legacy_bin.rs b/rust/pspp/src/output/spv/legacy_bin.rs
deleted file mode 100644 (file)
index f1e0e46..0000000
+++ /dev/null
@@ -1,280 +0,0 @@
-use std::{
-    collections::HashMap,
-    io::{Read, Seek, SeekFrom},
-};
-
-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,
-    format::{Category, Format},
-    output::{
-        pivot::Value,
-        spv::light::{U32String, decode_format, parse_vec},
-    },
-};
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-pub struct LegacyBin {
-    #[br(magic(0u8))]
-    version: Version,
-    #[br(temp)]
-    n_sources: u16,
-    member_size: u32,
-    #[br(count(n_sources), args { inner: (version,) })]
-    metadata: Vec<Metadata>,
-    #[br(parse_with(parse_data), args(metadata.as_slice()))]
-    data: Vec<Data>,
-    #[br(parse_with(parse_strings))]
-    strings: Option<Strings>,
-}
-
-impl LegacyBin {
-    pub fn decode(&self) -> HashMap<String, HashMap<String, Vec<DataValue>>> {
-        fn decode_asciiz(name: &[u8]) -> String {
-            let len = name.iter().position(|b| *b == 0).unwrap_or(name.len());
-            std::str::from_utf8(&name[..len]).unwrap().into() // XXX unwrap
-        }
-
-        let mut sources = HashMap::new();
-        for (metadata, data) in self.metadata.iter().zip(&self.data) {
-            let mut variables = HashMap::new();
-            for variable in &data.variables {
-                variables.insert(
-                    variable.variable_name.clone(),
-                    variable
-                        .values
-                        .iter()
-                        .map(|value| DataValue {
-                            index: None,
-                            value: Datum::Number((*value != f64::MIN).then_some(*value)),
-                        })
-                        .collect::<Vec<_>>(),
-                );
-            }
-            sources.insert(metadata.source_name.clone(), variables);
-        }
-        if let Some(strings) = &self.strings {
-            for map in &strings.source_maps {
-                let source = sources.get_mut(&map.source_name).unwrap(); // XXX unwrap
-                for var_map in &map.variable_maps {
-                    let variable = source.get_mut(&var_map.variable_name).unwrap(); // XXX unwrap
-                    for datum_map in &var_map.datum_maps {
-                        // XXX two possibly out-of-range indexes below
-                        variable[datum_map.value_idx].value =
-                            Datum::String(strings.labels[datum_map.label_idx].label.clone());
-                    }
-                }
-            }
-        }
-        sources
-    }
-}
-
-#[derive(Clone, Debug)]
-pub struct DataValue {
-    pub index: Option<f64>,
-    pub value: Datum<String>,
-}
-
-impl DataValue {
-    pub fn category(&self) -> Option<usize> {
-        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))
-    }
-
-    // This should probably be a method on some hypothetical FormatMap.
-    pub fn as_format(&self, format_map: &HashMap<i64, Format>) -> Format {
-        let f = match &self.value {
-            Datum::Number(Some(number)) => *number as i64,
-            Datum::Number(None) => 0,
-            Datum::String(s) => s.parse().unwrap_or_default(),
-        };
-        match format_map.get(&f) {
-            Some(format) => *format,
-            None => decode_format(f as u32),
-        }
-    }
-
-    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)]
-enum Version {
-    #[br(magic = 0xafu8)]
-    Vaf,
-    #[br(magic = 0xb0u8)]
-    Vb0,
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct Metadata {
-    n_values: u32,
-    n_variables: u32,
-    data_offset: u32,
-    #[br(parse_with(parse_fixed_utf8_string), args(if version == Version::Vaf { 28 } else { 64 }))]
-    source_name: String,
-    #[br(if(version == Version::Vb0), temp)]
-    _x: u32,
-}
-
-#[derive(Debug)]
-struct Data {
-    variables: Vec<Variable>,
-}
-
-#[binrw::parser(reader, endian)]
-fn parse_data(metadata: &[Metadata]) -> BinResult<Vec<Data>> {
-    let mut data = Vec::with_capacity(metadata.len());
-    for metadata in metadata {
-        reader.seek(SeekFrom::Start(metadata.data_offset as u64))?;
-        let mut variables = Vec::with_capacity(metadata.n_variables as usize);
-        for _ in 0..metadata.n_variables {
-            variables.push(Variable::read_options(
-                reader,
-                endian,
-                (metadata.n_values,),
-            )?);
-        }
-        data.push(Data { variables });
-    }
-    Ok(data)
-}
-
-impl BinRead for Data {
-    type Args<'a> = &'a [Metadata];
-
-    fn read_options<R: Read + Seek>(
-        reader: &mut R,
-        endian: binrw::Endian,
-        metadata: Self::Args<'_>,
-    ) -> binrw::BinResult<Self> {
-        let mut variables = Vec::with_capacity(metadata.len());
-        for metadata in metadata {
-            reader.seek(SeekFrom::Start(metadata.data_offset as u64))?;
-            variables.push(Variable::read_options(
-                reader,
-                endian,
-                (metadata.n_values,),
-            )?);
-        }
-        Ok(Self { variables })
-    }
-}
-
-#[binread]
-#[br(little, import(n_values: u32))]
-#[derive(Debug)]
-struct Variable {
-    #[br(parse_with(parse_fixed_utf8_string), args(288))]
-    variable_name: String,
-    #[br(count(n_values))]
-    values: Vec<f64>,
-}
-
-#[binrw::parser(reader, endian)]
-fn parse_strings() -> BinResult<Option<Strings>> {
-    let position = reader.stream_position()?;
-    let length = reader.seek(SeekFrom::End(0))?;
-    if position != length {
-        reader.seek(SeekFrom::Start(position))?;
-        Ok(Some(Strings::read_options(reader, endian, ())?))
-    } else {
-        Ok(None)
-    }
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct Strings {
-    #[br(parse_with(parse_vec))]
-    source_maps: Vec<SourceMap>,
-    #[br(parse_with(parse_vec))]
-    labels: Vec<Label>,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct SourceMap {
-    #[br(parse_with(parse_utf8_string))]
-    source_name: String,
-    #[br(parse_with(parse_vec))]
-    variable_maps: Vec<VariableMap>,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct VariableMap {
-    #[br(parse_with(parse_utf8_string))]
-    variable_name: String,
-    #[br(parse_with(parse_vec))]
-    datum_maps: Vec<DatumMap>,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct DatumMap {
-    #[br(map(|x: u32| x as usize))]
-    value_idx: usize,
-    #[br(map(|x: u32| x as usize))]
-    label_idx: usize,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct Label {
-    #[br(temp)]
-    _frequency: u32,
-    #[br(parse_with(parse_utf8_string))]
-    label: String,
-}
-
-/// Parses a UTF-8 string preceded by a 32-bit length.
-#[binrw::parser(reader, endian)]
-pub(super) fn parse_utf8_string() -> BinResult<String> {
-    Ok(U32String::read_options(reader, endian, ())?.decode(UTF_8))
-}
-
-/// Parses a UTF-8 string that is exactly `n` bytes long and whose contents end
-/// at the first null byte.
-#[binrw::parser(reader)]
-pub(super) fn parse_fixed_utf8_string(n: usize) -> BinResult<String> {
-    let mut buf = vec![0; n];
-    reader.read_exact(&mut buf)?;
-    let len = buf.iter().take_while(|b| **b != 0).count();
-    Ok(
-        std::str::from_utf8(&buf[..len]).unwrap().into(), // XXX unwrap
-    )
-}
diff --git a/rust/pspp/src/output/spv/legacy_xml.rs b/rust/pspp/src/output/spv/legacy_xml.rs
deleted file mode 100644 (file)
index c1523f5..0000000
+++ /dev/null
@@ -1,2694 +0,0 @@
-// 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/>.
-
-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 ordered_float::OrderedFloat;
-use serde::Deserialize;
-
-use crate::{
-    calendar::{date_time_to_pspp, time_to_pspp},
-    data::Datum,
-    format::{self, Decimal::Dot, F8_0, F40_2, Type, UncheckedFormat},
-    output::{
-        pivot::{
-            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,
-    },
-};
-
-#[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 new() -> Self {
-        Self::default()
-    }
-
-    fn remap_formats(
-        &mut self,
-        format: &Option<Format>,
-        string_format: &Option<StringFormat>,
-    ) -> (crate::format::Format, Vec<Affix>) {
-        let (format, affixes, relabels, try_strings_as_numbers) = if let Some(format) = &format {
-            (
-                Some(format.decode()),
-                format.affixes.clone(),
-                format.relabels.as_slice(),
-                format.try_strings_as_numbers.unwrap_or_default(),
-            )
-        } else if let Some(string_format) = &string_format {
-            (
-                None,
-                string_format.affixes.clone(),
-                string_format.relabels.as_slice(),
-                false,
-            )
-        } else {
-            (None, Vec::new(), [].as_slice(), false)
-        };
-        for relabel in relabels {
-            let value = if try_strings_as_numbers && let Ok(to) = relabel.to.trim().parse::<f64>() {
-                Datum::Number(Some(to))
-            } else if let Some(format) = format
-                && let Ok(to) = relabel.to.trim().parse::<f64>()
-            {
-                Datum::String(
-                    Datum::<String>::Number(Some(to))
-                        .display(format)
-                        .with_stretch()
-                        .to_string(),
-                )
-            } else {
-                Datum::String(relabel.to.clone())
-            };
-            self.0.insert(OrderedFloat(relabel.from), value);
-            // XXX warn on duplicate
-        }
-        (format.unwrap_or(F8_0), affixes)
-    }
-
-    fn apply(&self, data: &mut Vec<DataValue>) {
-        for value in data {
-            let Datum::Number(Some(number)) = value.value else {
-                continue;
-            };
-            if let Some(to) = self.0.get(&OrderedFloat(number)) {
-                value.index = Some(number);
-                value.value = to.clone();
-            }
-        }
-    }
-
-    fn insert_labels(
-        &mut self,
-        data: &[DataValue],
-        label_series: &Series,
-        format: crate::format::Format,
-    ) {
-        for (value, label) in data.iter().zip(label_series.values.iter()) {
-            if let Some(Some(number)) = value.value.as_number() {
-                let dest = match &label.value {
-                    Datum::Number(_) => label.value.display(format).with_stretch().to_string(),
-                    Datum::String(s) => s.clone(),
-                };
-                self.0.insert(OrderedFloat(number), Datum::String(dest));
-            }
-        }
-    }
-
-    fn remap_vmes(&mut self, value_map: &[ValueMapEntry]) {
-        for vme in value_map {
-            for from in vme.from.split(';') {
-                let from = from.trim().parse::<f64>().unwrap(); // XXX
-                let to = if let Ok(to) = vme.to.trim().parse::<f64>() {
-                    Datum::Number(Some(to))
-                } else {
-                    Datum::String(vme.to.clone())
-                };
-                self.0.insert(OrderedFloat(from), to);
-            }
-        }
-    }
-
-    fn lookup<'a>(&'a self, dv: &'a DataValue) -> &'a Datum<String> {
-        if let Datum::Number(Some(number)) = &dv.value
-            && let Some(value) = self.0.get(&OrderedFloat(*number))
-        {
-            value
-        } else {
-            &dv.value
-        }
-    }
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-pub struct Visualization {
-    /// In format `YYYY-MM-DD`.
-    #[serde(rename = "@date")]
-    date: String,
-    // Locale used for output, e.g. `en-US`.
-    #[serde(rename = "@lang")]
-    lang: String,
-    /// Localized title of the pivot table.
-    #[serde(rename = "@name")]
-    name: String,
-    /// Base style for the pivot table.
-    #[serde(rename = "@style")]
-    style: Ref<Style>,
-
-    #[serde(rename = "$value")]
-    children: Vec<VisChild>,
-}
-
-impl Visualization {
-    pub fn decode(
-        &self,
-        data: HashMap<String, HashMap<String, Vec<DataValue>>>,
-        mut look: Look,
-    ) -> Result<PivotTable, super::Error> {
-        let mut extension = None;
-        let mut user_source = None;
-        let mut source_variables = Vec::new();
-        let mut derived_variables = Vec::new();
-        let mut graph = None;
-        let mut labels = EnumMap::from_fn(|_| Vec::new());
-        let mut styles = HashMap::new();
-        let mut _layer_controller = None;
-        for child in &self.children {
-            match child {
-                VisChild::Extension(e) => extension = Some(e),
-                VisChild::UserSource(us) => user_source = Some(us),
-                VisChild::SourceVariable(source_variable) => source_variables.push(source_variable),
-                VisChild::DerivedVariable(derived_variable) => {
-                    derived_variables.push(derived_variable)
-                }
-                VisChild::CategoricalDomain(_) => (),
-                VisChild::Graph(g) => graph = Some(g),
-                VisChild::LabelFrame(label_frame) => {
-                    if let Some(label) = &label_frame.label
-                        && let Some(purpose) = label.purpose
-                    {
-                        labels[purpose].push(label);
-                    }
-                }
-                VisChild::Container(c) => {
-                    for label_frame in &c.label_frames {
-                        if let Some(label) = &label_frame.label
-                            && let Some(purpose) = label.purpose
-                        {
-                            labels[purpose].push(label);
-                        }
-                    }
-                }
-                VisChild::Style(style) => {
-                    if let Some(id) = &style.id {
-                        styles.insert(id.as_str(), style);
-                    }
-                }
-                VisChild::LayerController(lc) => _layer_controller = Some(lc),
-            }
-        }
-        let Some(graph) = graph else { todo!() };
-        let Some(_user_source) = user_source else {
-            todo!()
-        };
-
-        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,
-                );
-            }
-        }
-
-        // Footnotes.
-        //
-        // 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 footnote_builder = BTreeMap::<usize, Footnote>::new();
-        if let Some(f) = &graph.interval.footnotes {
-            f.decode(&mut footnote_builder);
-        }
-        for child in &graph.interval.labeling.children {
-            if let LabelingChild::Footnotes(f) = child {
-                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 = footnote_builder
-                        .entry(uses_reference.get() - 1)
-                        .or_default();
-                    if index % 2 == 0 {
-                        entry.content = text.text.strip_suffix('\n').unwrap_or(&text.text).into();
-                    } else {
-                        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),
-            (Purpose::SubTitle, Area::Caption),
-            (Purpose::Layer, Area::Layers),
-            (Purpose::Footnote, Area::Footer),
-        ] {
-            for label in &labels[purpose] {
-                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_area(
-                Some(*style),
-                graph.cell_style.get(&styles),
-                &mut look.areas[Area::Data(RowParity::Even)],
-            );
-            look.areas[Area::Data(RowParity::Odd)] =
-                look.areas[Area::Data(RowParity::Even)].clone();
-        }
-
-        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())
-            && let Some(width) = &style.width
-        {
-            let mut parts = width.split(';');
-            parts.next();
-            if let Some(min_width) = parts.next()
-                && let Some(max_width) = parts.next()
-                && let Ok(min_width) = min_width.parse::<Length>()
-                && let Ok(max_width) = max_width.parse::<Length>()
-            {
-                look.heading_widths[HeadingRegion::Columns] =
-                    min_width.as_pt_f64() as isize..=max_width.as_pt_f64() as isize;
-            }
-        }
-
-        let mut series = HashMap::<&str, Series>::new();
-        while let n_source = source_variables.len()
-            && let n_derived = derived_variables.len()
-            && (n_source > 0 || n_derived > 0)
-        {
-            for sv in take(&mut source_variables) {
-                match sv.decode(&data, &series) {
-                    Ok(s) => {
-                        series.insert(&sv.id, s);
-                    }
-                    Err(()) => source_variables.push(sv),
-                }
-            }
-
-            for dv in take(&mut derived_variables) {
-                match dv.decode(&series) {
-                    Ok(s) => {
-                        series.insert(&dv.id, s);
-                    }
-                    Err(()) => derived_variables.push(dv),
-                }
-            }
-
-            if n_source == source_variables.len() && n_derived == derived_variables.len() {
-                unreachable!();
-            }
-        }
-
-        fn decode_dimension<'a>(
-            variables: &[(&'a Series, usize)],
-            axes: &HashMap<usize, &Axis>,
-            styles: &HashMap<&str, &Style>,
-            a: Axis3,
-            look: &mut Look,
-            rotate_inner_column_labels: &mut bool,
-            rotate_outer_row_labels: &mut bool,
-            footnotes: &pivot::Footnotes,
-            dims: &mut Vec<Dim<'a>>,
-        ) {
-            let base_level = variables[0].1;
-            let show_label = if let Ok(a) = Axis2::try_from(a)
-                && let Some(axis) = axes.get(&(base_level + variables.len()))
-                && let Some(label) = &axis.label
-            {
-                let out = &mut look.areas[Area::Labels(a)];
-                *out = AreaStyle::default_for_area(Area::Labels(a));
-                let style = label.style.get(&styles);
-                Style::decode_area(
-                    style,
-                    label.text_frame_style.as_ref().and_then(|r| r.get(styles)),
-                    out,
-                );
-                style.is_some_and(|s| s.visible.unwrap_or_default())
-            } else {
-                false
-            };
-            if a == Axis3::Y
-                && let Some(axis) = axes.get(&(base_level + variables.len() - 1))
-            {
-                Style::decode_area(
-                    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;
-                }
-            }
-
-            let variables = variables
-                .into_iter()
-                .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();
-            for (index, value) in variables[0].values.iter().enumerate() {
-                let Some(row) = value.category() else {
-                    continue;
-                };
-                coordinate_to_index.insert(row, CategoryLocator::new_leaf(index));
-                let name = variables[0].new_name(value, footnotes);
-                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].leaves.start];
-                    if end < cats.len()
-                        && variables[j].values[cats[end].leaves.clone()]
-                            .iter()
-                            .all(|dv| &dv.value == &dv1.value)
-                    {
-                    } else {
-                        let name = variables[j].map.lookup(dv1);
-                        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].category.clone());
-                            }
-                            CatBuilder {
-                                category: Category::from(group),
-                                leaves: cats[start].leaves.start..cats[end - 1].leaves.end,
-                                location: cats[start].location.parent(),
-                            }
-                        } else {
-                            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;
-            }
-
-            let dimension = Dimension::new(
-                Group::new(
-                    variables[0]
-                        .label
-                        .as_ref()
-                        .map_or_else(|| Value::empty(), |label| Value::new_user_text(label)),
-                )
-                .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,
-                coordinate: variables[0],
-            });
-        }
-
-        fn decode_dimensions<'a, 'b>(
-            variables: impl IntoIterator<Item = &'a str>,
-            series: &'b HashMap<&str, Series>,
-            axes: &HashMap<usize, &Axis>,
-            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,
-            dims: &mut Vec<Dim<'b>>,
-        ) {
-            let variables = variables
-                .into_iter()
-                .zip(level_ofs..)
-                .map(|(variable_name, level)| {
-                    series
-                        .get(variable_name)
-                        .filter(|s| !s.values.is_empty())
-                        .map(|s| (s, level))
-                })
-                .collect::<Vec<_>>();
-            let mut dim_vars = Vec::new();
-            for var in variables {
-                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,
-                        rotate_inner_column_labels,
-                        rotate_outer_row_labels,
-                        footnotes,
-                        dims,
-                    );
-                    dim_vars.clear();
-                }
-            }
-            if !dim_vars.is_empty() {
-                decode_dimension(
-                    &dim_vars,
-                    axes,
-                    styles,
-                    a,
-                    look,
-                    rotate_inner_column_labels,
-                    rotate_outer_row_labels,
-                    footnotes,
-                    dims,
-                );
-            }
-        }
-
-        struct Dim<'a> {
-            axis: Axis3,
-            dimension: pivot::Dimension,
-            coordinate: &'a Series,
-        }
-
-        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();
-        let mut dims = Vec::new();
-        decode_dimensions(
-            columns.into_iter().map(|vr| vr.reference.as_str()),
-            &series,
-            &axes,
-            &styles,
-            Axis3::X,
-            &mut look,
-            &mut rotate_inner_column_labels,
-            &mut rotate_outer_row_labels,
-            &footnotes,
-            1,
-            &mut dims,
-        );
-        let rows = cross
-            .get(1)
-            .map(|child| child.variables())
-            .unwrap_or_default();
-        decode_dimensions(
-            rows.into_iter().map(|vr| vr.reference.as_str()),
-            &series,
-            &axes,
-            &styles,
-            Axis3::Y,
-            &mut look,
-            &mut rotate_inner_column_labels,
-            &mut rotate_outer_row_labels,
-            &footnotes,
-            1 + columns.len(),
-            &mut dims,
-        );
-
-        let mut level_ofs = columns.len() + rows.len() + 1;
-        for layers in [&graph.faceting.layers1, &graph.faceting.layers2] {
-            decode_dimensions(
-                layers.iter().map(|layer| layer.variable.as_str()),
-                &series,
-                &axes,
-                &styles,
-                Axis3::Y,
-                &mut look,
-                &mut rotate_inner_column_labels,
-                &mut rotate_outer_row_labels,
-                &footnotes,
-                level_ofs,
-                &mut dims,
-            );
-            level_ofs += layers.len();
-        }
-
-        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);
-        let cell_footnotes =
-            graph
-                .interval
-                .labeling
-                .children
-                .iter()
-                .find_map(|child| match child {
-                    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 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);
-            }
-
-            let format = if let Some(cell_formats) = &cell_formats {
-                // XXX indexing of values
-                cell_formats.values[i].as_format(&format_map)
-            } else {
-                F40_2
-            };
-            let mut value = cell.as_pivot_value(format);
-
-            if let Some(cell_footnotes) = &cell_footnotes {
-                // XXX indexing
-                let dv = &cell_footnotes.values[i];
-                if let Some(s) = dv.value.as_string() {
-                    for part in s.split(',') {
-                        if let Ok(index) = part.parse::<usize>()
-                            && let Some(index) = index.checked_sub(1)
-                            && let Some(footnote) = footnotes.get(index)
-                        {
-                            value = value.with_footnote(footnote);
-                        }
-                    }
-                }
-            }
-            if let Value {
-                inner: ValueInner::Number(NumberValue { value: None, .. }),
-                styling: None,
-            } = &value
-            {
-                // A system-missing value without a footnote represents an empty cell.
-            } else {
-                // XXX cell_index might be invalid?
-                data.insert(coords.clone(), value);
-            }
-        }
-
-        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 = AreaStyle::default_for_area(Area::Data(RowParity::Odd));
-                            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;
-                                };
-                                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)
-                && let Ok(index) = usize::try_from(index)
-                && let Some(footnote) = footnotes.get(index)
-            {
-                value = value.with_footnote(footnote);
-            }
-        }
-        value
-    }
-
-    fn max_category(&self) -> Option<usize> {
-        self.values
-            .iter()
-            .filter_map(|value| value.category())
-            .max()
-    }
-
-    fn new_name(&self, dv: &DataValue, footnotes: &pivot::Footnotes) -> Value {
-        let dv = self.map.lookup(dv);
-        let name = Value::new_datum(dv);
-        self.add_affixes(name, &footnotes)
-    }
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-enum VisChild {
-    Extension(VisualizationExtension),
-    UserSource(UserSource),
-    SourceVariable(SourceVariable),
-    DerivedVariable(DerivedVariable),
-    CategoricalDomain(CategoricalDomain),
-    Graph(Graph),
-    LabelFrame(LabelFrame),
-    Container(Container),
-    Style(Style),
-    LayerController(LayerController),
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename = "extension", rename_all = "camelCase")]
-struct VisualizationExtension {
-    #[serde(rename = "@showGridline")]
-    show_gridline: Option<bool>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-enum Variable {
-    SourceVariable(SourceVariable),
-    DerivedVariable(DerivedVariable),
-}
-
-#[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")]
-    source_name: 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>>,
-
-    #[serde(default, rename = "extension")]
-    extensions: Vec<VariableExtension>,
-    format: Option<Format>,
-    string_format: Option<StringFormat>,
-}
-
-impl SourceVariable {
-    fn decode(
-        &self,
-        data: &HashMap<String, HashMap<String, Vec<DataValue>>>,
-        series: &HashMap<&str, Series>,
-    ) -> Result<Series, ()> {
-        let label_series = if let Some(label_variable) = &self.label_variable {
-            let Some(label_series) = series.get(label_variable.references.as_str()) else {
-                return Err(());
-            };
-            Some(label_series)
-        } else {
-            None
-        };
-
-        let Some(data) = data
-            .get(&self.source)
-            .and_then(|source| source.get(&self.source_name))
-        else {
-            todo!()
-        };
-        let mut map = Map::new();
-        let (format, affixes) = map.remap_formats(&self.format, &self.string_format);
-        let mut data = data.clone();
-        if !map.0.is_empty() {
-            map.apply(&mut data);
-        } else if let Some(label_series) = label_series {
-            map.insert_labels(&data, label_series, format);
-        }
-        Ok(Series::new(self.id.clone(), data, map)
-            .with_format(format)
-            .with_affixes(affixes)
-            .with_label(self.label.clone()))
-    }
-}
-
-#[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,
-    #[serde(default, rename = "extension")]
-    extensions: Vec<VariableExtension>,
-    format: Option<Format>,
-    string_format: Option<StringFormat>,
-    #[serde(default, rename = "valueMapEntry")]
-    value_map: Vec<ValueMapEntry>,
-}
-
-impl DerivedVariable {
-    fn decode(&self, series: &HashMap<&str, Series>) -> Result<Series, ()> {
-        let mut values = if self.value == "constant(0)" {
-            let n_values = if let Some(series) = series.values().next() {
-                series.values.len()
-            } else {
-                return Err(());
-            };
-            (0..n_values)
-                .map(|_| DataValue {
-                    index: Some(0.0),
-                    value: Datum::Number(Some(0.0)),
-                })
-                .collect()
-        } 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 Err(());
-            };
-            dependency.values.clone()
-        } else {
-            unreachable!()
-        };
-        let mut map = Map::new();
-        map.remap_vmes(&self.value_map);
-        map.apply(&mut values);
-        map.remap_formats(&self.format, &self.string_format);
-        if values
-            .iter()
-            .all(|value| value.value.is_string_and(|s| s.is_empty()))
-        {
-            values.clear();
-        }
-        Ok(Series::new(self.id.clone(), values, map))
-    }
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename = "extension", rename_all = "camelCase")]
-struct VariableExtension {
-    #[serde(rename = "@from")]
-    from: String,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct UserSource {
-    #[serde(rename = "@id")]
-    id: String,
-
-    #[serde(rename = "@missing")]
-    missing: Option<Missing>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct CategoricalDomain {
-    variable_reference: VariableReference,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct VariableReference {
-    #[serde(rename = "@ref")]
-    reference: String,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "camelCase")]
-enum Missing {
-    Listwise,
-    Pairwise,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct StringFormat {
-    #[serde(default, rename = "relabel")]
-    relabels: Vec<Relabel>,
-    #[serde(default, rename = "affix")]
-    affixes: Vec<Affix>,
-}
-
-#[derive(Deserialize, Debug, Default)]
-#[serde(rename_all = "camelCase")]
-struct Format {
-    #[serde(rename = "@baseFormat")]
-    base_format: Option<BaseFormat>,
-    #[serde(rename = "@errorCharacter")]
-    error_character: Option<char>,
-    #[serde(rename = "@separatorChars")]
-    separator_chars: Option<String>,
-    #[serde(rename = "@mdyOrder")]
-    mdy_order: Option<MdyOrder>,
-    #[serde(rename = "@showYear")]
-    show_year: Option<bool>,
-    #[serde(rename = "@showQuarter")]
-    show_quarter: Option<bool>,
-    #[serde(rename = "@quarterPrefix")]
-    quarter_prefix: Option<String>,
-    #[serde(rename = "@quarterSuffix")]
-    quarter_suffix: Option<String>,
-    #[serde(rename = "@yearAbbreviation")]
-    year_abbreviation: Option<bool>,
-    #[serde(rename = "@showMonth")]
-    show_month: Option<bool>,
-    #[serde(rename = "@monthFormat")]
-    month_format: Option<MonthFormat>,
-    #[serde(rename = "@dayPadding")]
-    day_padding: Option<bool>,
-    #[serde(rename = "@dayOfMonthPadding")]
-    day_of_month_padding: Option<bool>,
-    #[serde(rename = "@showWeek")]
-    show_week: Option<bool>,
-    #[serde(rename = "@weekPadding")]
-    week_padding: Option<bool>,
-    #[serde(rename = "@weekSuffix")]
-    week_suffix: Option<String>,
-    #[serde(rename = "@showDayOfWeek")]
-    show_day_of_week: Option<bool>,
-    #[serde(rename = "@dayOfWeekAbbreviation")]
-    day_of_week_abbreviation: Option<bool>,
-    #[serde(rename = "hourPadding")]
-    hour_padding: Option<bool>,
-    #[serde(rename = "minutePadding")]
-    minute_padding: Option<bool>,
-    #[serde(rename = "secondPadding")]
-    second_padding: Option<bool>,
-    #[serde(rename = "@showDay")]
-    show_day: Option<bool>,
-    #[serde(rename = "@showHour")]
-    show_hour: Option<bool>,
-    #[serde(rename = "@showMinute")]
-    show_minute: Option<bool>,
-    #[serde(rename = "@showSecond")]
-    show_second: Option<bool>,
-    #[serde(rename = "@showMillis")]
-    show_millis: Option<bool>,
-    #[serde(rename = "@dayType")]
-    day_type: Option<DayType>,
-    #[serde(rename = "@hourFormat")]
-    hour_format: Option<HourFormat>,
-    #[serde(rename = "@minimumIntegerDigits")]
-    minimum_integer_digits: Option<usize>,
-    #[serde(rename = "@maximumFractionDigits")]
-    maximum_fraction_digits: Option<i64>,
-    #[serde(rename = "@minimumFractionDigits")]
-    minimum_fraction_digits: Option<usize>,
-    #[serde(rename = "@useGrouping")]
-    use_grouping: Option<bool>,
-    #[serde(rename = "@scientific")]
-    scientific: Option<Scientific>,
-    #[serde(rename = "@small")]
-    small: Option<f64>,
-    #[serde(default, rename = "@prefix")]
-    prefix: String,
-    #[serde(default, rename = "@suffix")]
-    suffix: String,
-    #[serde(rename = "@tryStringsAsNumbers")]
-    try_strings_as_numbers: Option<bool>,
-    #[serde(rename = "@negativesOutside")]
-    negatives_outside: Option<bool>,
-    #[serde(default, rename = "relabel")]
-    relabels: Vec<Relabel>,
-    #[serde(default, rename = "affix")]
-    affixes: Vec<Affix>,
-}
-
-impl Format {
-    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 = "@minimumIntegerDigits")]
-    minimum_integer_digits: Option<i64>,
-    #[serde(rename = "@maximumFractionDigits")]
-    maximum_fraction_digits: Option<i64>,
-    #[serde(rename = "@minimumFractionDigits")]
-    minimum_fraction_digits: Option<i64>,
-    #[serde(rename = "@useGrouping")]
-    use_grouping: Option<bool>,
-    #[serde(rename = "@scientific")]
-    scientific: Option<Scientific>,
-    #[serde(rename = "@small")]
-    small: Option<f64>,
-    #[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 = "@separatorChars")]
-    separator_chars: Option<String>,
-    #[serde(rename = "@mdyOrder")]
-    mdy_order: Option<MdyOrder>,
-    #[serde(rename = "@showYear")]
-    show_year: Option<bool>,
-    #[serde(rename = "@showQuarter")]
-    show_quarter: Option<bool>,
-    #[serde(rename = "@quarterPrefix")]
-    quarter_prefix: Option<String>,
-    #[serde(rename = "@quarterSuffix")]
-    quarter_suffix: Option<String>,
-    #[serde(rename = "@yearAbbreviation")]
-    year_abbreviation: Option<bool>,
-    #[serde(rename = "@showMonth")]
-    show_month: Option<bool>,
-    #[serde(rename = "@monthFormat")]
-    month_format: Option<MonthFormat>,
-    #[serde(rename = "@dayPadding")]
-    day_padding: Option<bool>,
-    #[serde(rename = "@dayOfMonthPadding")]
-    day_of_month_padding: Option<bool>,
-    #[serde(rename = "@showWeek")]
-    show_week: Option<bool>,
-    #[serde(rename = "@weekPadding")]
-    week_padding: Option<bool>,
-    #[serde(rename = "@weekSuffix")]
-    week_suffix: Option<String>,
-    #[serde(rename = "@showDayOfWeek")]
-    show_day_of_week: Option<bool>,
-    #[serde(rename = "@dayOfWeekAbbreviation")]
-    day_of_week_abbreviation: Option<bool>,
-    #[serde(rename = "hourPadding")]
-    hour_padding: Option<bool>,
-    #[serde(rename = "minutePadding")]
-    minute_padding: Option<bool>,
-    #[serde(rename = "secondPadding")]
-    second_padding: Option<bool>,
-    #[serde(rename = "@showDay")]
-    show_day: Option<bool>,
-    #[serde(rename = "@showHour")]
-    show_hour: Option<bool>,
-    #[serde(rename = "@showMinute")]
-    show_minute: Option<bool>,
-    #[serde(rename = "@showSecond")]
-    show_second: Option<bool>,
-    #[serde(rename = "@showMillis")]
-    show_millis: Option<bool>,
-    #[serde(rename = "@dayType")]
-    day_type: Option<DayType>,
-    #[serde(rename = "@hourFormat")]
-    hour_format: Option<HourFormat>,
-    #[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)
-    }
-}
-
-impl DateTimeFormat {
-    fn decode(&self) -> crate::format::Format {
-        SignificantDateTimeFormat::from(self).decode()
-    }
-}
-
-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 = "@dayPadding")]
-    day_padding: Option<bool>,
-    #[serde(rename = "hourPadding")]
-    hour_padding: Option<bool>,
-    #[serde(rename = "minutePadding")]
-    minute_padding: Option<bool>,
-    #[serde(rename = "secondPadding")]
-    second_padding: Option<bool>,
-    #[serde(rename = "@showDay")]
-    show_day: Option<bool>,
-    #[serde(rename = "@showHour")]
-    show_hour: Option<bool>,
-    #[serde(rename = "@showMinute")]
-    show_minute: Option<bool>,
-    #[serde(rename = "@showSecond")]
-    show_second: Option<bool>,
-    #[serde(rename = "@showMillis")]
-    show_millis: Option<bool>,
-    #[serde(rename = "@showYear")]
-    show_year: Option<bool>,
-    #[serde(default, rename = "affix")]
-    affixes: Vec<Affix>,
-}
-
-impl ElapsedTimeFormat {
-    fn decode(&self) -> crate::format::Format {
-        let type_ = 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)
-    }
-}
-
-#[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 DayType {
-    Month,
-    Year,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "camelCase")]
-enum HourFormat {
-    #[serde(rename = "AMPM")]
-    AmPm,
-    #[serde(rename = "AS_24")]
-    As24,
-    #[serde(rename = "AS_12")]
-    As12,
-}
-
-#[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 {
-    /// The footnote number as a natural number: 1 for the first footnote, 2 for
-    /// the second, and so on.
-    #[serde(rename = "@definesReference")]
-    defines_reference: u64,
-
-    /// Position for the footnote label.
-    #[serde(rename = "@position")]
-    position: Position,
-
-    /// Whether the affix is a suffix (true) or a prefix (false).
-    #[serde(rename = "@suffix")]
-    suffix: bool,
-
-    /// The text of the suffix or prefix. Typically a letter, e.g. `a` for
-    /// footnote 1, `b` for footnote 2, ...
-    #[serde(rename = "@value")]
-    value: String,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "camelCase")]
-enum Position {
-    Subscript,
-    Superscript,
-}
-
-#[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>,
-
-    /// Not used.
-    #[serde(rename = "@color2")]
-    color2: Option<Color>,
-
-    /// Normally 0. The value -90 causes inner column or outer row labels to be
-    /// rotated vertically.
-    #[serde(rename = "@labelAngle")]
-    label_angle: Option<f64>,
-
-    #[serde(rename = "@border-bottom")]
-    border_bottom: Option<Border>,
-
-    #[serde(rename = "@border-top")]
-    border_top: Option<Border>,
-
-    #[serde(rename = "@border-left")]
-    border_left: Option<Border>,
-
-    #[serde(rename = "@border-right")]
-    border_right: Option<Border>,
-
-    #[serde(rename = "@border-bottom-color")]
-    border_bottom_color: Option<Color>,
-
-    #[serde(rename = "@border-top-color")]
-    border_top_color: Option<Color>,
-
-    #[serde(rename = "@border-left-color")]
-    border_left_color: Option<Color>,
-
-    #[serde(rename = "@border-right-color")]
-    border_right_color: Option<Color>,
-
-    #[serde(rename = "@font-family")]
-    font_family: Option<String>,
-
-    #[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 = "@margin-bottom")]
-    margin_bottom: Option<Length>,
-
-    #[serde(rename = "@margin-top")]
-    margin_top: Option<Length>,
-
-    #[serde(rename = "@margin-left")]
-    margin_left: Option<Length>,
-
-    #[serde(rename = "@margin-right")]
-    margin_right: Option<Length>,
-
-    #[serde(rename = "@textAlignment")]
-    text_alignment: Option<TextAlignment>,
-
-    #[serde(rename = "@labelLocationHorizontal")]
-    label_location_horizontal: Option<LabelLocation>,
-
-    #[serde(rename = "@labelLocationVertical")]
-    label_location_vertical: Option<LabelLocation>,
-
-    #[serde(rename = "@size")]
-    size: Option<String>,
-
-    #[serde(rename = "@width")]
-    width: Option<String>,
-
-    #[serde(rename = "@visible")]
-    visible: Option<bool>,
-
-    #[serde(rename = "@decimal-offset")]
-    decimal_offset: Option<Length>,
-}
-
-impl Style {
-    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(_)) => 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 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 {
-                font_style.bold = weight.is_bold();
-            }
-            if let Some(style) = fg.font_style {
-                font_style.italic = style.is_italic();
-            }
-            if let Some(underline) = fg.font_underline {
-                font_style.underline = underline.is_underline();
-            }
-            if let Some(color) = 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()
-                {
-                    font_style.size = size;
-                } else {
-                    // XXX warn?
-                }
-            }
-            if let Some(alignment) = fg.text_alignment {
-                cell_style.horz_align = alignment.as_horz_align(fg.decimal_offset);
-            }
-            if let Some(label_local_vertical) = fg.label_location_vertical {
-                cell_style.vert_align = label_local_vertical.into();
-            }
-        }
-        if let Some(bg) = bg {
-            if let Some(color) = 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)]
-#[serde(rename_all = "camelCase")]
-enum Border {
-    Solid,
-    Thick,
-    Thin,
-    Double,
-    None,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "camelCase")]
-enum FontWeight {
-    Regular,
-    Bold,
-}
-
-impl FontWeight {
-    fn is_bold(&self) -> bool {
-        *self == Self::Bold
-    }
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "camelCase")]
-enum FontStyle {
-    Regular,
-    Italic,
-}
-
-impl FontStyle {
-    fn is_italic(&self) -> bool {
-        *self == Self::Italic
-    }
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "camelCase")]
-enum FontUnderline {
-    None,
-    Underline,
-}
-
-impl FontUnderline {
-    fn is_underline(&self) -> bool {
-        *self == Self::Underline
-    }
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "camelCase")]
-enum TextAlignment {
-    Left,
-    Right,
-    Center,
-    Decimal,
-    Mixed,
-}
-
-impl TextAlignment {
-    fn as_horz_align(&self, decimal_offset: Option<Length>) -> Option<HorzAlign> {
-        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_f64(),
-                decimal: Dot,
-            }),
-            TextAlignment::Mixed => None,
-        }
-    }
-}
-
-#[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 {
-    #[serde(rename = "@id")]
-    id: Option<String>,
-
-    #[serde(rename = "@cellStyle")]
-    cell_style: Ref<Style>,
-
-    #[serde(rename = "@style")]
-    style: Ref<Style>,
-
-    #[serde(rename = "location")]
-    locations: Vec<Location>,
-    coordinates: Coordinates,
-    faceting: Faceting,
-    facet_layout: FacetLayout,
-    interval: Interval,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Coordinates;
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Location {
-    /// The part of the table being located.
-    #[serde(rename = "@part")]
-    part: Part,
-
-    /// How the location is determined.
-    #[serde(rename = "@method")]
-    method: Method,
-
-    /// Minimum size.
-    #[serde(rename = "@min")]
-    min: Option<Length>,
-
-    /// Maximum size.
-    #[serde(rename = "@max")]
-    max: Option<Length>,
-
-    /// An element to attach to. Required when method is attach or same, not
-    /// observed otherwise.
-    #[serde(rename = "@target")]
-    target: Option<String>,
-
-    #[serde(rename = "@value")]
-    value: Option<String>,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "camelCase")]
-enum Part {
-    Height,
-    Width,
-    Top,
-    Bottom,
-    Left,
-    Right,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "camelCase")]
-enum Method {
-    SizeToContent,
-    Attach,
-    Fixed,
-    Same,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Faceting {
-    #[serde(rename = "@id")]
-    id: Option<String>,
-
-    #[serde(default)]
-    layers1: Vec<Layer>,
-    cross: Cross,
-    #[serde(default)]
-    layers2: Vec<Layer>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Cross {
-    #[serde(rename = "$value")]
-    children: Vec<CrossChild>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-enum CrossChild {
-    /// No dimensions along this axis.
-    Unity,
-    /// Dimensions along this axis.
-    Nest(Nest),
-}
-
-impl CrossChild {
-    fn variables(&self) -> &[VariableReference] {
-        match self {
-            CrossChild::Unity => &[],
-            CrossChild::Nest(nest) => &nest.variable_references,
-        }
-    }
-}
-
-#[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,
-
-    #[serde(rename = "@visible")]
-    visible: Option<bool>,
-
-    #[serde(rename = "@titleVisible")]
-    title_visible: Option<bool>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct FacetLayout {
-    table_layout: TableLayout,
-    #[serde(rename = "$value")]
-    children: Vec<FacetLayoutChild>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-enum FacetLayoutChild {
-    SetCellProperties(SetCellProperties),
-    FacetLevel(FacetLevel),
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct TableLayout {
-    #[serde(rename = "@verticalTitlesInCorner")]
-    vertical_titles_in_corner: bool,
-
-    #[serde(rename = "@style")]
-    style: Option<Ref<Style>>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct SetCellProperties {
-    #[serde(rename = "@id")]
-    id: Option<String>,
-
-    #[serde(rename = "@applyToConverse")]
-    apply_to_converse: Option<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),
-    IntersectWhere(IntersectWhere),
-    Alternating,
-    #[serde(other)]
-    Empty,
-}
-
-#[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")]
-struct IntersectWhere {
-    #[serde(rename = "@variable")]
-    variable: String,
-
-    #[serde(rename = "@variable2")]
-    variable2: String,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-enum Set {
-    SetStyle(SetStyle),
-    SetFrameStyle(SetFrameStyle),
-    SetFormat(SetFormat),
-    SetMetaData(SetMetaData),
-}
-
-#[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 SetMetaData {
-    #[serde(rename = "@target")]
-    target: Ref<Graph>,
-
-    #[serde(rename = "@key")]
-    key: String,
-
-    #[serde(rename = "@value")]
-    value: String,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct SetFormat {
-    #[serde(rename = "@target")]
-    target: String,
-
-    #[serde(rename = "@reset")]
-    reset: Option<bool>,
-
-    #[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 SetFrameStyle {
-    #[serde(rename = "@style")]
-    style: Ref<Style>,
-
-    #[serde(rename = "@target")]
-    target: Ref<MajorTicks>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Interval {
-    #[serde(rename = "@id")]
-    id: Option<String>,
-
-    #[serde(rename = "@style")]
-    style: Ref<Style>,
-
-    labeling: Labeling,
-    footnotes: Option<Footnotes>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Labeling {
-    #[serde(rename = "@id")]
-    id: Option<String>,
-
-    #[serde(rename = "@style")]
-    style: Option<Ref<Style>>,
-
-    #[serde(rename = "@variable")]
-    variable: String,
-
-    #[serde(default)]
-    children: Vec<LabelingChild>,
-}
-
-impl Labeling {
-    fn decode_format_map<'a>(
-        &self,
-        series: &'a HashMap<&str, Series>,
-    ) -> (Option<&'a Series>, HashMap<i64, crate::format::Format>) {
-        let mut map = HashMap::new();
-        let mut cell_format = None;
-        for child in &self.children {
-            if let LabelingChild::Formatting(formatting) = child {
-                cell_format = series.get(formatting.variable.as_str());
-                for mapping in &formatting.mappings {
-                    if let Some(format) = &mapping.format {
-                        map.insert(mapping.from, format.decode());
-                    }
-                }
-            }
-        }
-        (cell_format, map)
-    }
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-enum LabelingChild {
-    Formatting(Formatting),
-    Format(Format),
-    Footnotes(Footnotes),
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Formatting {
-    #[serde(rename = "@variable")]
-    variable: String,
-
-    mappings: Vec<FormatMapping>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct FormatMapping {
-    #[serde(rename = "@from")]
-    from: i64,
-
-    format: Option<Format>,
-}
-
-#[derive(Clone, Debug, Default)]
-struct Footnote {
-    content: String,
-    marker: Option<String>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Footnotes {
-    #[serde(rename = "@superscript")]
-    superscript: Option<bool>,
-
-    #[serde(rename = "@variable")]
-    variable: String,
-
-    #[serde(default, rename = "footnoteMapping")]
-    mappings: Vec<FootnoteMapping>,
-}
-
-impl Footnotes {
-    fn decode(&self, dst: &mut BTreeMap<usize, Footnote>) {
-        for f in &self.mappings {
-            dst.entry(f.defines_reference.get() - 1)
-                .or_default()
-                .content = f.to.clone();
-        }
-    }
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct FootnoteMapping {
-    #[serde(rename = "@definesReference")]
-    defines_reference: NonZeroUsize,
-
-    #[serde(rename = "@from")]
-    from: i64,
-
-    #[serde(rename = "@to")]
-    to: String,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct FacetLevel {
-    #[serde(rename = "@id")]
-    id: Option<String>,
-
-    #[serde(rename = "@level")]
-    level: usize,
-
-    #[serde(rename = "@gap")]
-    gap: Option<Length>,
-    axis: Axis,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Axis {
-    #[serde(rename = "@style")]
-    style: Ref<Style>,
-
-    label: Option<Label>,
-    major_ticks: MajorTicks,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct MajorTicks {
-    #[serde(rename = "@id")]
-    id: String,
-
-    #[serde(rename = "@labelAngle")]
-    label_angle: f64,
-
-    #[serde(rename = "@length")]
-    length: Length,
-
-    #[serde(rename = "@style")]
-    style: Ref<Style>,
-
-    #[serde(rename = "@tickFrameStyle")]
-    tick_frame_style: Ref<Style>,
-
-    #[serde(rename = "@labelFrequency")]
-    label_frequency: Option<i64>,
-
-    #[serde(rename = "@stagger")]
-    stagger: Option<bool>,
-
-    gridline: Option<Gridline>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Gridline {
-    #[serde(rename = "@style")]
-    style: Ref<Style>,
-
-    #[serde(rename = "@zOrder")]
-    z_order: i64,
-}
-
-#[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,
-}
-
-impl Label {
-    fn text(&self) -> &[Text] {
-        match &self.child {
-            LabelChild::Text(texts) => texts.as_slice(),
-            LabelChild::DescriptionGroup(_) => &[],
-        }
-    }
-
-    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_area(fg, bg, area_style);
-    }
-}
-#[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>),
-    DescriptionGroup(DescriptionGroup),
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Text {
-    #[serde(rename = "@usesReference")]
-    uses_reference: Option<NonZeroUsize>,
-
-    #[serde(rename = "@definesReference")]
-    defines_reference: Option<NonZeroUsize>,
-
-    #[serde(rename = "@position")]
-    position: Option<Position>,
-
-    #[serde(rename = "@style")]
-    style: Option<Ref<Style>>,
-
-    #[serde(default, rename = "$text")]
-    text: String,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct DescriptionGroup {
-    #[serde(rename = "@target")]
-    target: Ref<Faceting>,
-
-    #[serde(rename = "@separator")]
-    separator: Option<String>,
-
-    #[serde(rename = "$value")]
-    children: Vec<DescriptionGroupChild>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-enum DescriptionGroupChild {
-    Description(Description),
-    Text(Text),
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Description {
-    #[serde(rename = "@name")]
-    name: Name,
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
-#[serde(rename_all = "camelCase")]
-enum Name {
-    Variable,
-    Value,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct LabelFrame {
-    #[serde(rename = "@id")]
-    id: Option<String>,
-
-    #[serde(rename = "@style")]
-    style: Option<Ref<Style>>,
-
-    #[serde(rename = "location")]
-    locations: Vec<Location>,
-
-    label: Option<Label>,
-    paragraph: Option<Paragraph>,
-}
-
-impl LabelFrame {
-    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
-        }
-    }
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Paragraph;
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Container {
-    #[serde(rename = "@style")]
-    style: Ref<Style>,
-
-    #[serde(default, rename = "extension")]
-    extensions: Option<ContainerExtension>,
-    #[serde(default)]
-    locations: Vec<Location>,
-    #[serde(default)]
-    label_frames: Vec<LabelFrame>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename = "extension", rename_all = "camelCase")]
-struct ContainerExtension {
-    #[serde(rename = "@combinedFootnotes")]
-    combined_footnotes: Option<bool>,
-}
-
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct LayerController {
-    #[serde(rename = "@target")]
-    target: Option<Ref<Label>>,
-}
diff --git a/rust/pspp/src/output/spv/light.rs b/rust/pspp/src/output/spv/light.rs
deleted file mode 100644 (file)
index 263f751..0000000
+++ /dev/null
@@ -1,1683 +0,0 @@
-use std::{
-    fmt::Debug,
-    io::{Cursor, Read, Seek},
-    ops::Deref,
-    str::FromStr,
-    sync::Arc,
-};
-
-use binrw::{
-    BinRead, BinResult, Endian, Error as BinError, VecArgs, binread, error::ContextExt,
-    io::TakeSeekExt,
-};
-use chrono::DateTime;
-use displaydoc::Display;
-use encoding_rs::{Encoding, WINDOWS_1252};
-use enum_map::{EnumMap, enum_map};
-
-use crate::{
-    format::{
-        CC, Decimal, Decimals, Epoch, F40, Format, NumberStyle, Settings, Type, UncheckedFormat,
-        Width,
-    },
-    output::pivot::{
-        self, AreaStyle, Axis2, Axis3, BoxBorder, Color, FootnoteMarkerPosition,
-        FootnoteMarkerType, Footnotes, Group, HeadingRegion, HorzAlign, LabelPosition, Look,
-        PivotTable, PivotTableMetadata, PivotTableStyle, PrecomputedIndex, RowColBorder, RowParity,
-        StringValue, Stroke, TemplateValue, ValueStyle, VariableValue, VertAlign, parse_bool,
-    },
-    settings::Show,
-};
-
-#[derive(Debug, Display, thiserror::Error)]
-pub enum LightError {
-    /// Expected {expected} dimensions along axes, found {actual} dimensions ({n_layers} layers + {n_rows} rows + {n_columns} columns).
-    WrongAxisCount {
-        expected: usize,
-        actual: usize,
-        n_layers: usize,
-        n_rows: usize,
-        n_columns: usize,
-    },
-
-    /// Invalid dimension index {index} in table with {n} dimensions.
-    InvalidDimensionIndex { index: usize, n: usize },
-
-    /// Dimension with index {0} appears twice in table axes.
-    DuplicateDimensionIndex(usize),
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-pub struct LightTable {
-    header: Header,
-    #[br(args(header.version))]
-    titles: Titles,
-    #[br(parse_with(parse_vec), args(header.version))]
-    footnotes: Vec<Footnote>,
-    #[br(args(header.version))]
-    areas: Areas,
-    #[br(parse_with(parse_counted))]
-    borders: Borders,
-    #[br(parse_with(parse_counted))]
-    print_settings: PrintSettings,
-    #[br(if(header.version == Version::V3), parse_with(parse_counted))]
-    table_settings: TableSettings,
-    #[br(if(header.version == Version::V1), temp)]
-    _ts: Option<Counted<Sponge>>,
-    #[br(args(header.version))]
-    formats: Formats,
-    #[br(parse_with(parse_vec), args(header.version))]
-    dimensions: Vec<Dimension>,
-    axes: Axes,
-    #[br(parse_with(parse_vec), args(header.version))]
-    cells: Vec<Cell>,
-}
-
-impl LightTable {
-    fn decode_look(&self, encoding: &'static Encoding) -> Look {
-        Look {
-            name: self.table_settings.table_look.decode_optional(encoding),
-            hide_empty: self.table_settings.omit_empty,
-            row_label_position: if self.table_settings.show_row_labels_in_corner {
-                LabelPosition::Corner
-            } else {
-                LabelPosition::Nested
-            },
-            heading_widths: enum_map! {
-                HeadingRegion::Rows => self.header.min_row_heading_width as isize..=self.header.max_row_heading_width as isize,
-                HeadingRegion::Columns => self.header.min_column_heading_width as isize..=self.header.max_column_heading_width as isize,
-            },
-            footnote_marker_type: if self.table_settings.show_alphabetic_markers {
-                FootnoteMarkerType::Alphabetic
-            } else {
-                FootnoteMarkerType::Numeric
-            },
-            footnote_marker_position: if self.table_settings.footnote_marker_subscripts {
-                FootnoteMarkerPosition::Subscript
-            } else {
-                FootnoteMarkerPosition::Superscript
-            },
-            areas: self.areas.decode(encoding),
-            borders: self.borders.decode(),
-            print_all_layers: self.print_settings.alll_layers,
-            paginate_layers: self.print_settings.paginate_layers,
-            shrink_to_fit: enum_map! {
-                Axis2::X => self.print_settings.fit_width,
-                Axis2::Y => self.print_settings.fit_length,
-            },
-            top_continuation: self.print_settings.top_continuation,
-            bottom_continuation: self.print_settings.bottom_continuation,
-            continuation: self
-                .print_settings
-                .continuation_string
-                .decode_optional(encoding),
-            n_orphan_lines: self.print_settings.n_orphan_lines,
-        }
-    }
-
-    pub fn decode(&self) -> Result<PivotTable, LightError> {
-        let encoding = self.formats.encoding();
-
-        let n1 = self.formats.n1();
-        let n2 = self.formats.n2();
-        let n3 = self.formats.n3();
-        let n3_inner = n3.and_then(|n3| n3.inner.as_ref());
-        let y1 = self.formats.y1();
-        let footnotes = self
-            .footnotes
-            .iter()
-            .map(|f| f.decode(encoding, &Footnotes::new()))
-            .collect();
-        let cells = self
-            .cells
-            .iter()
-            .map(|cell| {
-                (
-                    PrecomputedIndex(cell.index as usize),
-                    cell.value.decode(encoding, &footnotes),
-                )
-            })
-            .collect::<Vec<_>>();
-        let dimensions = self
-            .dimensions
-            .iter()
-            .map(|d| {
-                let mut root = Group::new(d.name.decode(encoding, &footnotes))
-                    .with_show_label(!d.hide_dim_label);
-                for category in &d.categories {
-                    category.decode(encoding, &footnotes, &mut root);
-                }
-                pivot::Dimension {
-                    presentation_order: (0..root.len()).collect(), /*XXX*/
-                    root,
-                    hide_all_labels: d.hide_all_labels,
-                }
-            })
-            .collect::<Vec<_>>();
-        let pivot_table = PivotTable::new(self.axes.decode(dimensions)?)
-            .with_style(PivotTableStyle {
-                look: Arc::new(self.decode_look(encoding)),
-                rotate_inner_column_labels: self.header.rotate_inner_column_labels,
-                rotate_outer_row_labels: self.header.rotate_outer_row_labels,
-                show_grid_lines: self.borders.show_grid_lines,
-                show_title: n1.map_or(true, |x1| x1.show_title != 10),
-                show_caption: n1.map_or(true, |x1| x1.show_caption),
-                show_values: n1.map_or(None, |x1| x1.show_values),
-                show_variables: n1.map_or(None, |x1| x1.show_variables),
-                sizing: self.table_settings.sizing.decode(
-                    &self.formats.column_widths,
-                    n2.map_or(&[], |x2| &x2.row_heights),
-                ),
-                settings: Settings {
-                    epoch: self.formats.y0.epoch(),
-                    decimal: self.formats.y0.decimal(),
-                    leading_zero: y1.map_or(false, |y1| y1.include_leading_zero),
-                    ccs: self.formats.custom_currency.decode(encoding),
-                },
-                grouping: {
-                    let grouping = self.formats.y0.grouping;
-                    b",.' ".contains(&grouping).then_some(grouping as char)
-                },
-                small: n3.map_or(0.0, |n3| n3.small),
-                weight_format: F40,
-            })
-            .with_metadata(PivotTableMetadata {
-                command_local: y1.map(|y1| y1.command_local.decode(encoding)),
-                command_c: y1.map(|y1| y1.command.decode(encoding)),
-                language: y1.map(|y1| y1.language.decode(encoding)),
-                locale: y1.map(|y1| y1.locale.decode(encoding)),
-                dataset: n3_inner.and_then(|strings| strings.dataset.decode_optional(encoding)),
-                datafile: n3_inner.and_then(|strings| strings.datafile.decode_optional(encoding)),
-                date: n3_inner.and_then(|inner| {
-                    if inner.date != 0 {
-                        DateTime::from_timestamp(inner.date as i64, 0).map(|dt| dt.naive_utc())
-                    } else {
-                        None
-                    }
-                }),
-                title: Some(Box::new(self.titles.title.decode(encoding, &footnotes))),
-                subtype: Some(Box::new(self.titles.subtype.decode(encoding, &footnotes))),
-                corner_text: self
-                    .titles
-                    .corner_text
-                    .as_ref()
-                    .map(|corner| Box::new(corner.decode(encoding, &footnotes))),
-                caption: self
-                    .titles
-                    .caption
-                    .as_ref()
-                    .map(|caption| Box::new(caption.decode(encoding, &footnotes))),
-                notes: self.table_settings.notes.decode_optional(encoding),
-            })
-            .with_footnotes(footnotes)
-            .with_data(cells);
-        Ok(pivot_table)
-    }
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct Header {
-    #[br(magic = b"\x01\0")]
-    version: Version,
-    #[br(parse_with(parse_bool), temp)]
-    _x0: bool,
-    #[br(parse_with(parse_bool), temp)]
-    _x1: bool,
-    #[br(parse_with(parse_bool))]
-    rotate_inner_column_labels: bool,
-    #[br(parse_with(parse_bool))]
-    rotate_outer_row_labels: bool,
-    #[br(parse_with(parse_bool), temp)]
-    _x2: bool,
-    #[br(temp)]
-    _x3: i32,
-    min_column_heading_width: u32,
-    max_column_heading_width: u32,
-    min_row_heading_width: u32,
-    max_row_heading_width: u32,
-    table_id: i64,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
-enum Version {
-    #[br(magic = 1u32)]
-    V1,
-    #[br(magic = 3u32)]
-    V3,
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct Titles {
-    #[br(args(version))]
-    title: Value,
-    #[br(temp)]
-    _1: Optional<One>,
-    #[br(args(version))]
-    subtype: Value,
-    #[br(temp)]
-    _2: Optional<One>,
-    #[br(magic = b'1')]
-    #[br(args(version))]
-    user_title: Value,
-    #[br(temp)]
-    _3: Optional<One>,
-    #[br(parse_with(parse_explicit_optional), args(version))]
-    corner_text: Option<Value>,
-    #[br(parse_with(parse_explicit_optional), args(version))]
-    caption: Option<Value>,
-}
-
-#[binread]
-#[br(little, magic = 1u8)]
-#[derive(Debug)]
-struct One;
-
-#[binread]
-#[br(little, magic = 0u8)]
-#[derive(Debug)]
-struct Zero;
-
-#[binrw::parser(reader, endian)]
-pub fn parse_explicit_optional<'a, T, A>(args: A, ...) -> BinResult<Option<T>>
-where
-    T: BinRead<Args<'a> = A>,
-{
-    let byte = <u8>::read_options(reader, endian, ())?;
-    match byte {
-        b'1' => Ok(Some(T::read_options(reader, endian, args)?)),
-        b'X' => Ok(None),
-        _ => Err(BinError::NoVariantMatch {
-            pos: reader.stream_position()? - 1,
-        }),
-    }
-}
-
-#[binrw::parser(reader, endian)]
-pub(super) fn parse_vec<T, A>(inner: A, ...) -> BinResult<Vec<T>>
-where
-    for<'a> T: BinRead<Args<'a> = A>,
-    A: Clone,
-    T: 'static,
-{
-    let count = u32::read_options(reader, endian, ())? as usize;
-    <Vec<T>>::read_options(reader, endian, VecArgs { count, inner })
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct Footnote {
-    #[br(args(version))]
-    text: Value,
-    #[br(parse_with(parse_explicit_optional))]
-    #[br(args(version))]
-    marker: Option<Value>,
-    show: i32,
-}
-
-impl Footnote {
-    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Footnote {
-        pivot::Footnote::new(self.text.decode(encoding, footnotes))
-            .with_marker(self.marker.as_ref().map(|m| m.decode(encoding, footnotes)))
-            .with_show(self.show > 0)
-    }
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct Areas {
-    #[br(temp)]
-    _1: Optional<Zero>,
-    #[br(args(version))]
-    areas: [Area; 8],
-}
-
-impl Areas {
-    fn decode(&self, encoding: &'static Encoding) -> EnumMap<pivot::Area, AreaStyle> {
-        EnumMap::from_fn(|area| {
-            let index = match area {
-                pivot::Area::Title => 0,
-                pivot::Area::Caption => 1,
-                pivot::Area::Footer => 2,
-                pivot::Area::Corner => 3,
-                pivot::Area::Labels(Axis2::X) => 4,
-                pivot::Area::Labels(Axis2::Y) => 5,
-                pivot::Area::Data(_) => 6,
-                pivot::Area::Layers => 7,
-            };
-            let data_row = match area {
-                pivot::Area::Data(row) => row,
-                _ => RowParity::default(),
-            };
-            self.areas[index].decode(encoding, data_row)
-        })
-    }
-}
-
-#[binrw::parser(reader, endian)]
-fn parse_color() -> BinResult<Color> {
-    let pos = reader.stream_position()?;
-    let string = U32String::read_options(reader, endian, ())?;
-    let string = string.decode(WINDOWS_1252);
-    if string.is_empty() {
-        Ok(Color::BLACK)
-    } else {
-        Color::from_str(&string).map_err(|error| binrw::Error::Custom {
-            pos,
-            err: Box::new(error),
-        })
-    }
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct Area {
-    #[br(temp)]
-    _index: u8,
-    #[br(magic = b'1')]
-    typeface: U32String,
-    size: f32,
-    style: i32,
-    #[br(parse_with(parse_bool))]
-    underline: bool,
-    halign: i32,
-    valign: i32,
-    #[br(parse_with(parse_color))]
-    fg: Color,
-    #[br(parse_with(parse_color))]
-    bg: Color,
-    #[br(parse_with(parse_bool))]
-    alternate: bool,
-    #[br(parse_with(parse_color))]
-    alt_fg: Color,
-    #[br(parse_with(parse_color))]
-    alt_bg: Color,
-    #[br(if(version == Version::V3))]
-    margins: Margins,
-}
-
-impl Area {
-    fn decode(&self, encoding: &'static Encoding, data_row: RowParity) -> AreaStyle {
-        AreaStyle {
-            cell_style: pivot::CellStyle {
-                horz_align: match self.halign {
-                    0 => Some(HorzAlign::Center),
-                    2 => Some(HorzAlign::Left),
-                    4 => Some(HorzAlign::Right),
-                    _ => None,
-                },
-                vert_align: match self.valign {
-                    0 => VertAlign::Middle,
-                    3 => VertAlign::Bottom,
-                    _ => VertAlign::Top,
-                },
-                margins: enum_map! {
-                    Axis2::X => [self.margins.left_margin, self.margins.right_margin],
-                    Axis2::Y => [self.margins.top_margin, self.margins.bottom_margin]
-                },
-            },
-            font_style: pivot::FontStyle {
-                bold: (self.style & 1) != 0,
-                italic: (self.style & 2) != 0,
-                underline: self.underline,
-                font: self.typeface.decode(encoding),
-                fg: if data_row == RowParity::Odd && self.alternate {
-                    self.alt_fg
-                } else {
-                    self.fg
-                },
-                bg: if data_row == RowParity::Odd && self.alternate {
-                    self.alt_bg
-                } else {
-                    self.bg
-                },
-                size: (self.size / 1.33) as i32,
-            },
-        }
-    }
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug, Default)]
-struct Margins {
-    left_margin: i32,
-    right_margin: i32,
-    top_margin: i32,
-    bottom_margin: i32,
-}
-
-#[binread]
-#[br(big)]
-#[derive(Debug)]
-struct Borders {
-    #[br(magic(1u32), parse_with(parse_vec))]
-    borders: Vec<Border>,
-
-    #[br(parse_with(parse_bool))]
-    show_grid_lines: bool,
-
-    #[br(temp, magic(b"\0\0\0"))]
-    _1: (),
-}
-
-impl Borders {
-    fn decode(&self) -> EnumMap<pivot::Border, pivot::BorderStyle> {
-        let mut borders = pivot::Border::default_borders();
-        for border in &self.borders {
-            if let Some((border, style)) = border.decode() {
-                borders[border] = style;
-            } else {
-                // warning
-            }
-        }
-        borders
-    }
-}
-
-#[binread]
-#[br(big)]
-#[derive(Debug)]
-struct Border {
-    #[br(map(|index: u32| index as usize))]
-    index: usize,
-    stroke: i32,
-    color: u32,
-}
-
-impl Border {
-    fn decode(&self) -> Option<(pivot::Border, pivot::BorderStyle)> {
-        let border = match self.index {
-            0 => pivot::Border::Title,
-            1 => pivot::Border::OuterFrame(BoxBorder::Left),
-            2 => pivot::Border::OuterFrame(BoxBorder::Top),
-            3 => pivot::Border::OuterFrame(BoxBorder::Right),
-            4 => pivot::Border::OuterFrame(BoxBorder::Bottom),
-            5 => pivot::Border::InnerFrame(BoxBorder::Left),
-            6 => pivot::Border::InnerFrame(BoxBorder::Top),
-            7 => pivot::Border::InnerFrame(BoxBorder::Right),
-            8 => pivot::Border::InnerFrame(BoxBorder::Bottom),
-            9 => pivot::Border::DataLeft,
-            10 => pivot::Border::DataLeft,
-            11 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)),
-            12 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)),
-            13 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)),
-            14 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)),
-            15 => pivot::Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)),
-            16 => pivot::Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)),
-            17 => pivot::Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)),
-            18 => pivot::Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)),
-            _ => return None,
-        };
-
-        let stroke = match self.stroke {
-            0 => Stroke::None,
-            2 => Stroke::Dashed,
-            3 => Stroke::Thick,
-            4 => Stroke::Thin,
-            6 => Stroke::Double,
-            _ => Stroke::Solid,
-        };
-
-        let color = Color::new(
-            (self.color >> 16) as u8,
-            (self.color >> 8) as u8,
-            self.color as u8,
-        )
-        .with_alpha((self.color >> 24) as u8);
-
-        Some((border, pivot::BorderStyle { stroke, color }))
-    }
-}
-
-#[binread]
-#[br(big)]
-#[derive(Debug)]
-struct PrintSettings {
-    #[br(magic = b"\0\0\0\x01")]
-    #[br(parse_with(parse_bool))]
-    alll_layers: bool,
-    #[br(parse_with(parse_bool))]
-    paginate_layers: bool,
-    #[br(parse_with(parse_bool))]
-    fit_width: bool,
-    #[br(parse_with(parse_bool))]
-    fit_length: bool,
-    #[br(parse_with(parse_bool))]
-    top_continuation: bool,
-    #[br(parse_with(parse_bool))]
-    bottom_continuation: bool,
-    #[br(map(|n: u32| n as usize))]
-    n_orphan_lines: usize,
-    continuation_string: U32String,
-}
-
-#[binread]
-#[br(big)]
-#[derive(Debug, Default)]
-struct TableSettings {
-    #[br(temp, magic = 1u32)]
-    _x5: i32,
-    current_layer: i32,
-    #[br(parse_with(parse_bool))]
-    omit_empty: bool,
-    #[br(parse_with(parse_bool))]
-    show_row_labels_in_corner: bool,
-    #[br(parse_with(parse_bool))]
-    show_alphabetic_markers: bool,
-    #[br(parse_with(parse_bool))]
-    footnote_marker_subscripts: bool,
-    #[br(temp)]
-    _x6: u8,
-    #[br(big, parse_with(parse_counted))]
-    sizing: Sizing,
-    notes: U32String,
-    table_look: U32String,
-    #[br(temp)]
-    _sponge: Sponge,
-}
-
-#[binread]
-#[br(big)]
-#[derive(Debug, Default)]
-struct Sizing {
-    #[br(parse_with(parse_vec))]
-    row_breaks: Vec<u32>,
-    #[br(parse_with(parse_vec))]
-    column_breaks: Vec<u32>,
-    #[br(parse_with(parse_vec))]
-    row_keeps: Vec<(i32, i32)>,
-    #[br(parse_with(parse_vec))]
-    column_keeps: Vec<(i32, i32)>,
-    #[br(parse_with(parse_vec))]
-    row_point_keeps: Vec<[i32; 3]>,
-    #[br(parse_with(parse_vec))]
-    column_point_keeps: Vec<[i32; 3]>,
-}
-
-impl Sizing {
-    fn decode(
-        &self,
-        column_widths: &[i32],
-        row_heights: &[i32],
-    ) -> EnumMap<Axis2, Option<Box<pivot::Sizing>>> {
-        fn decode_axis(
-            widths: &[i32],
-            breaks: &[u32],
-            keeps: &[(i32, i32)],
-        ) -> Option<Box<pivot::Sizing>> {
-            if widths.is_empty() && breaks.is_empty() && keeps.is_empty() {
-                None
-            } else {
-                Some(Box::new(pivot::Sizing {
-                    widths: widths.into(),
-                    breaks: breaks.into_iter().map(|b| *b as usize).collect(),
-                    keeps: keeps
-                        .into_iter()
-                        .map(|(low, high)| *low as usize..*high as usize)
-                        .collect(),
-                }))
-            }
-        }
-
-        enum_map! {
-            Axis2::X => decode_axis(column_widths, &self.column_breaks, &self.column_keeps),
-            Axis2::Y => decode_axis(row_heights, &self.row_breaks, &self.row_keeps),
-        }
-    }
-}
-
-#[binread]
-#[derive(Default)]
-pub(super) struct U32String {
-    #[br(parse_with(parse_vec))]
-    string: Vec<u8>,
-}
-
-impl U32String {
-    pub(super) fn decode(&self, encoding: &'static Encoding) -> String {
-        if let Ok(string) = str::from_utf8(&self.string) {
-            string.into()
-        } else {
-            encoding
-                .decode_without_bom_handling(&self.string)
-                .0
-                .into_owned()
-        }
-    }
-    pub(super) fn decode_optional(&self, encoding: &'static Encoding) -> Option<String> {
-        let string = self.decode(encoding);
-        if !string.is_empty() {
-            Some(string)
-        } else {
-            None
-        }
-    }
-}
-
-impl Debug for U32String {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        let s = self.string.iter().map(|c| *c as char).collect::<String>();
-        write!(f, "{s:?}")
-    }
-}
-
-#[binread]
-struct CountedInner {
-    #[br(parse_with(parse_vec))]
-    data: Vec<u8>,
-}
-
-impl CountedInner {
-    fn cursor(self) -> Cursor<Vec<u8>> {
-        Cursor::new(self.data)
-    }
-}
-
-impl Debug for CountedInner {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "{:?}", &self.data)
-    }
-}
-
-#[derive(Clone, Debug, Default)]
-struct Counted<T>(T);
-
-impl<T> Deref for Counted<T> {
-    type Target = T;
-
-    fn deref(&self) -> &Self::Target {
-        &self.0
-    }
-}
-
-impl<T> BinRead for Counted<T>
-where
-    T: BinRead,
-{
-    type Args<'a> = T::Args<'a>;
-
-    fn read_options<R: Read + Seek>(
-        reader: &mut R,
-        endian: binrw::Endian,
-        args: Self::Args<'_>,
-    ) -> BinResult<Self> {
-        let count = u32::read_options(reader, endian, ())? as u64;
-        let start = reader.stream_position()?;
-        let end = start + count;
-        let mut inner = reader.take_seek(count);
-        let result = <T>::read_options(&mut inner, Endian::Little, args)?;
-        let pos = inner.stream_position()?;
-        if pos != end {
-            let consumed = pos - start;
-            return Err(binrw::Error::Custom {
-                pos,
-                err: Box::new(format!(
-                    "counted data not exhausted (consumed {consumed} bytes out of {count})"
-                )),
-            });
-        }
-        Ok(Self(result))
-    }
-}
-
-#[binrw::parser(reader, endian)]
-fn parse_counted<T, A>(args: A, ...) -> BinResult<T>
-where
-    for<'a> T: BinRead<Args<'a> = A>,
-    A: Clone,
-    T: 'static,
-{
-    Ok(<Counted<T>>::read_options(reader, endian, args)?.0)
-}
-
-/// `BinRead` for `Option<T>` always requires the value to be there.  This
-/// instead tries to read it and falls back to None if there's no match.
-#[derive(Clone, Debug)]
-struct Optional<T>(Option<T>);
-
-impl<T> Default for Optional<T> {
-    fn default() -> Self {
-        Self(None)
-    }
-}
-
-impl<T> Deref for Optional<T> {
-    type Target = Option<T>;
-
-    fn deref(&self) -> &Self::Target {
-        &self.0
-    }
-}
-
-impl<T> BinRead for Optional<T>
-where
-    T: BinRead,
-{
-    type Args<'a> = T::Args<'a>;
-
-    fn read_options<R: Read + Seek>(
-        reader: &mut R,
-        endian: binrw::Endian,
-        args: Self::Args<'_>,
-    ) -> BinResult<Self> {
-        let start = reader.stream_position()?;
-        let result = <T>::read_options(reader, endian, args).ok();
-        if result.is_none() {
-            reader.seek(std::io::SeekFrom::Start(start))?;
-        }
-        Ok(Self(result))
-    }
-}
-
-#[binread]
-#[br(little)]
-#[br(import(version: Version))]
-#[derive(Debug)]
-struct Formats {
-    #[br(parse_with(parse_vec))]
-    column_widths: Vec<i32>,
-    locale: U32String,
-    current_layer: i32,
-    #[br(temp, parse_with(parse_bool))]
-    _x7: bool,
-    #[br(temp, parse_with(parse_bool))]
-    _x8: bool,
-    #[br(temp, parse_with(parse_bool))]
-    _x9: bool,
-    y0: Y0,
-    custom_currency: CustomCurrency,
-    #[br(if(version == Version::V1))]
-    v1: Counted<Optional<N0>>,
-    #[br(if(version == Version::V3))]
-    v3: Option<Counted<FormatsV3>>,
-}
-
-impl Formats {
-    fn y1(&self) -> Option<&Y1> {
-        self.v1
-            .as_ref()
-            .map(|n0| &n0.y1)
-            .or_else(|| self.v3.as_ref().map(|v3| &v3.n3.y1))
-    }
-
-    fn n1(&self) -> Option<&N1> {
-        self.v3.as_ref().map(|v3| &v3.n1_n2.x1)
-    }
-
-    fn n2(&self) -> Option<&N2> {
-        self.v3.as_ref().map(|v3| &v3.n1_n2.x2)
-    }
-
-    fn n3(&self) -> Option<&N3> {
-        self.v3.as_ref().map(|v3| &v3.n3)
-    }
-
-    fn charset(&self) -> Option<&U32String> {
-        self.y1().map(|y1| &y1.charset)
-    }
-
-    fn encoding(&self) -> &'static Encoding {
-        // XXX We should probably warn for unknown encodings below
-        if let Some(charset) = self.charset()
-            && let Some(encoding) = Encoding::for_label(&charset.string)
-        {
-            encoding
-        } else if let Ok(locale) = str::from_utf8(&self.locale.string)
-            && let Some(dot) = locale.find('.')
-            && let Some(encoding) = Encoding::for_label(locale[dot + 1..].as_bytes())
-        {
-            encoding
-        } else {
-            WINDOWS_1252
-        }
-    }
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct FormatsV3 {
-    #[br(parse_with(parse_counted))]
-    n1_n2: N1N2,
-    #[br(parse_with(parse_counted))]
-    n3: N3,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct N1N2 {
-    x1: N1,
-    #[br(parse_with(parse_counted))]
-    x2: N2,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct N0 {
-    #[br(temp)]
-    _bytes: [u8; 14],
-    y1: Y1,
-    y2: Y2,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct Y1 {
-    command: U32String,
-    command_local: U32String,
-    language: U32String,
-    charset: U32String,
-    locale: U32String,
-    #[br(temp, parse_with(parse_bool))]
-    _x10: bool,
-    #[br(parse_with(parse_bool))]
-    include_leading_zero: bool,
-    #[br(temp, parse_with(parse_bool))]
-    _x12: bool,
-    #[br(temp, parse_with(parse_bool))]
-    _x13: bool,
-    y0: Y0,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct Y2 {
-    custom_currency: CustomCurrency,
-    missing: u8,
-    #[br(temp, parse_with(parse_bool))]
-    _x17: bool,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct N1 {
-    #[br(temp, parse_with(parse_bool))]
-    _x14: bool,
-    show_title: u8,
-    #[br(temp, parse_with(parse_bool))]
-    _x16: bool,
-    lang: u8,
-    #[br(parse_with(parse_show))]
-    show_variables: Option<Show>,
-    #[br(parse_with(parse_show))]
-    show_values: Option<Show>,
-    #[br(temp)]
-    _x18: i32,
-    #[br(temp)]
-    _x19: i32,
-    #[br(temp)]
-    _zeros: [u8; 17],
-    #[br(temp, parse_with(parse_bool))]
-    _x20: bool,
-    #[br(parse_with(parse_bool))]
-    show_caption: bool,
-}
-
-#[binrw::parser(reader, endian)]
-fn parse_show() -> BinResult<Option<Show>> {
-    match <u8>::read_options(reader, endian, ())? {
-        0 => Ok(None),
-        1 => Ok(Some(Show::Value)),
-        2 => Ok(Some(Show::Label)),
-        3 => Ok(Some(Show::Both)),
-        _ => {
-            // XXX warn about invalid value
-            Ok(None)
-        }
-    }
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct N2 {
-    #[br(parse_with(parse_vec))]
-    row_heights: Vec<i32>,
-    #[br(parse_with(parse_vec))]
-    style_map: Vec<(i64, i16)>,
-    #[br(parse_with(parse_vec))]
-    styles: Vec<StylePair>,
-    #[br(parse_with(parse_counted))]
-    tail: Optional<[u8; 8]>,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct N3 {
-    #[br(temp, magic = b"\x01\0")]
-    _x21: u8,
-    #[br(magic = b"\0\0\0")]
-    y1: Y1,
-    small: f64,
-    #[br(magic = 1u8, temp)]
-    _one: (),
-    inner: Optional<N3Inner>,
-    y2: Y2,
-    #[br(temp)]
-    _tail: Optional<N3Tail>,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct N3Inner {
-    dataset: U32String,
-    datafile: U32String,
-    notes_unexpanded: U32String,
-    date: i32,
-    #[br(magic = 0u32, temp)]
-    _tail: (),
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct N3Tail {
-    #[br(temp)]
-    _x22: i32,
-    #[br(temp, assert(_zero == 0))]
-    _zero: i32,
-    #[br(temp, assert(_x25.is_none_or(|x25| x25 == 0 || x25 == 1)))]
-    _x25: Optional<u8>,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct Y0 {
-    epoch: i32,
-    decimal: u8,
-    grouping: u8,
-}
-
-impl Y0 {
-    fn epoch(&self) -> Epoch {
-        if (1000..=9999).contains(&self.epoch) {
-            Epoch(self.epoch)
-        } else {
-            Epoch::default()
-        }
-    }
-
-    fn decimal(&self) -> Decimal {
-        // XXX warn about bad decimal point?
-        Decimal::try_from(self.decimal as char).unwrap_or_default()
-    }
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct CustomCurrency {
-    #[br(parse_with(parse_vec))]
-    ccs: Vec<U32String>,
-}
-
-impl CustomCurrency {
-    fn decode(&self, encoding: &'static Encoding) -> EnumMap<CC, Option<Box<NumberStyle>>> {
-        let mut ccs = EnumMap::default();
-        for (cc, string) in enum_iterator::all().zip(&self.ccs) {
-            if let Ok(style) = NumberStyle::from_str(&string.decode(encoding)) {
-                ccs[cc] = Some(Box::new(style));
-            } else {
-                // XXX warning
-            }
-        }
-        ccs
-    }
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct ValueNumber {
-    #[br(parse_with(parse_explicit_optional), args(version))]
-    mods: Option<ValueMods>,
-    #[br(parse_with(parse_format))]
-    format: Format,
-    x: f64,
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct ValueVarNumber {
-    #[br(parse_with(parse_explicit_optional), args(version))]
-    mods: Option<ValueMods>,
-    #[br(parse_with(parse_format))]
-    format: Format,
-    x: f64,
-    var_name: U32String,
-    value_label: U32String,
-    #[br(parse_with(parse_show))]
-    show: Option<Show>,
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct ValueText {
-    local: U32String,
-    #[br(parse_with(parse_explicit_optional), args(version))]
-    mods: Option<ValueMods>,
-    id: U32String,
-    c: U32String,
-    #[br(parse_with(parse_bool))]
-    fixed: bool,
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct ValueString {
-    #[br(parse_with(parse_explicit_optional), args(version))]
-    mods: Option<ValueMods>,
-    #[br(parse_with(parse_format))]
-    format: Format,
-    value_label: U32String,
-    var_name: U32String,
-    #[br(parse_with(parse_show))]
-    show: Option<Show>,
-    s: U32String,
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct ValueVarName {
-    #[br(parse_with(parse_explicit_optional), args(version))]
-    mods: Option<ValueMods>,
-    var_name: U32String,
-    var_label: U32String,
-    #[br(parse_with(parse_show))]
-    show: Option<Show>,
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct ValueFixedText {
-    local: U32String,
-    #[br(parse_with(parse_explicit_optional), args(version))]
-    mods: Option<ValueMods>,
-    id: U32String,
-    c: U32String,
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct ValueTemplate {
-    #[br(parse_with(parse_explicit_optional), args(version))]
-    mods: Option<ValueMods>,
-    template: U32String,
-    #[br(parse_with(parse_vec), args(version))]
-    args: Vec<Argument>,
-}
-
-#[derive(Debug)]
-enum Value {
-    Number(ValueNumber),
-    VarNumber(ValueVarNumber),
-    Text(ValueText),
-    String(ValueString),
-    VarName(ValueVarName),
-    FixedText(ValueFixedText),
-    Template(ValueTemplate),
-}
-
-impl BinRead for Value {
-    type Args<'a> = (Version,);
-
-    fn read_options<R: Read + Seek>(
-        reader: &mut R,
-        endian: Endian,
-        args: Self::Args<'_>,
-    ) -> BinResult<Self> {
-        let start = reader.stream_position()?;
-        let kind = loop {
-            let x = <u8>::read_options(reader, endian, ())?;
-            if x != 0 {
-                break x;
-            }
-        };
-        match kind {
-            1 => ValueNumber::read_options(reader, endian, args).map(Self::Number),
-            2 => Ok(Self::VarNumber(ValueVarNumber::read_options(
-                reader, endian, args,
-            )?)),
-            3 => Ok(Self::Text(ValueText::read_options(reader, endian, args)?)),
-            4 => Ok(Self::String(ValueString::read_options(
-                reader, endian, args,
-            )?)),
-            5 => Ok(Self::VarName(ValueVarName::read_options(
-                reader, endian, args,
-            )?)),
-            6 => Ok(Self::FixedText(ValueFixedText::read_options(
-                reader, endian, args,
-            )?)),
-            b'1' | b'X' => {
-                reader.seek(std::io::SeekFrom::Current(-1))?;
-                Ok(Self::Template(ValueTemplate::read_options(
-                    reader, endian, args,
-                )?))
-            }
-            _ => Err(BinError::NoVariantMatch { pos: start }),
-        }
-        .map_err(|e| e.with_message(format!("while parsing Value starting at offset {start:#x}")))
-    }
-}
-
-pub(super) fn decode_format(raw: u32) -> Format {
-    if raw == 0 || raw == 0x10000 || raw == 1 {
-        return Format::new(Type::F, 40, 2).unwrap();
-    }
-
-    let raw_type = (raw >> 16) as u16;
-    let type_ = if raw_type >= 40 {
-        Type::F
-    } else if let Ok(type_) = Type::try_from(raw_type) {
-        type_
-    } else {
-        // XXX warn
-        Type::F
-    };
-    let w = ((raw >> 8) & 0xff) as Width;
-    let d = raw as Decimals;
-
-    UncheckedFormat::new(type_, w, d).fix()
-}
-
-#[binrw::parser(reader, endian)]
-fn parse_format() -> BinResult<Format> {
-    Ok(decode_format(u32::read_options(reader, endian, ())?))
-}
-
-impl ValueNumber {
-    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
-        pivot::Value::new_number_with_format((self.x != -f64::MAX).then_some(self.x), self.format)
-            .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
-    }
-}
-
-impl ValueVarNumber {
-    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
-        pivot::Value::new_number_with_format((self.x != -f64::MAX).then_some(self.x), self.format)
-            .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
-            .with_value_label(self.value_label.decode_optional(encoding))
-            .with_variable_name(Some(self.var_name.decode(encoding)))
-            .with_show_value_label(self.show)
-    }
-}
-
-impl ValueText {
-    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
-        pivot::Value::new_general_text(
-            self.local.decode(encoding),
-            self.c.decode(encoding),
-            self.id.decode(encoding),
-            !self.fixed,
-        )
-        .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
-    }
-}
-
-impl ValueString {
-    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
-        pivot::Value::new(pivot::ValueInner::String(StringValue {
-            s: self.s.decode(encoding),
-            hex: self.format.type_() == Type::AHex,
-            show: self.show,
-            var_name: self.var_name.decode_optional(encoding),
-            value_label: self.value_label.decode_optional(encoding),
-        }))
-        .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
-    }
-}
-
-impl ValueVarName {
-    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
-        pivot::Value::new(pivot::ValueInner::Variable(VariableValue {
-            show: self.show,
-            var_name: self.var_name.decode(encoding),
-            variable_label: self.var_label.decode_optional(encoding),
-        }))
-        .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
-    }
-}
-impl ValueFixedText {
-    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
-        pivot::Value::new_general_text(
-            self.local.decode(encoding),
-            self.c.decode(encoding),
-            self.id.decode(encoding),
-            false,
-        )
-        .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
-    }
-}
-
-impl ValueTemplate {
-    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
-        pivot::Value::new(pivot::ValueInner::Template(TemplateValue {
-            args: self
-                .args
-                .iter()
-                .map(|argument| argument.decode(encoding, footnotes))
-                .collect(),
-            localized: self.template.decode(encoding),
-            id: self
-                .mods
-                .as_ref()
-                .and_then(|mods| mods.template_id(encoding)),
-        }))
-        .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
-    }
-}
-
-impl Value {
-    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
-        match self {
-            Value::Number(number) => number.decode(encoding, footnotes),
-            Value::VarNumber(var_number) => var_number.decode(encoding, footnotes),
-            Value::Text(text) => text.decode(encoding, footnotes),
-            Value::String(string) => string.decode(encoding, footnotes),
-            Value::VarName(var_name) => var_name.decode(encoding, footnotes),
-            Value::FixedText(fixed_text) => fixed_text.decode(encoding, footnotes),
-            Value::Template(template) => template.decode(encoding, footnotes),
-        }
-    }
-}
-
-#[derive(Debug)]
-struct Argument(Vec<Value>);
-
-impl BinRead for Argument {
-    type Args<'a> = (Version,);
-
-    fn read_options<R: Read + Seek>(
-        reader: &mut R,
-        endian: Endian,
-        (version,): (Version,),
-    ) -> BinResult<Self> {
-        let count = u32::read_options(reader, endian, ())? as usize;
-        if count == 0 {
-            Ok(Self(vec![Value::read_options(reader, endian, (version,))?]))
-        } else {
-            let zero = u32::read_options(reader, endian, ())?;
-            assert_eq!(zero, 0);
-            let values = <Vec<_>>::read_options(
-                reader,
-                endian,
-                VecArgs {
-                    count,
-                    inner: (version,),
-                },
-            )?;
-            Ok(Self(values))
-        }
-    }
-}
-
-impl Argument {
-    fn decode(
-        &self,
-        encoding: &'static Encoding,
-        footnotes: &pivot::Footnotes,
-    ) -> Vec<pivot::Value> {
-        self.0
-            .iter()
-            .map(|value| value.decode(encoding, footnotes))
-            .collect()
-    }
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct ValueMods {
-    #[br(parse_with(parse_vec))]
-    refs: Vec<i16>,
-    #[br(parse_with(parse_vec))]
-    subscripts: Vec<U32String>,
-    #[br(if(version == Version::V1))]
-    v1: Option<ValueModsV1>,
-    #[br(if(version == Version::V3), parse_with(parse_counted))]
-    v3: ValueModsV3,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug, Default)]
-struct ValueModsV1 {
-    #[br(temp, magic(0u8), assert(_1 == 1 || _1 == 2))]
-    _1: i32,
-    #[br(temp)]
-    _0: Optional<Zero>,
-    #[br(temp)]
-    _1: Optional<Zero>,
-    #[br(temp)]
-    _2: i32,
-    #[br(temp)]
-    _3: Optional<Zero>,
-    #[br(temp)]
-    _4: Optional<Zero>,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug, Default)]
-struct ValueModsV3 {
-    #[br(parse_with(parse_counted))]
-    template_string: Optional<TemplateString>,
-    style_pair: StylePair,
-}
-
-impl ValueMods {
-    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> ValueStyle {
-        let font_style =
-            self.v3
-                .style_pair
-                .font_style
-                .as_ref()
-                .map(|font_style| pivot::FontStyle {
-                    bold: font_style.bold,
-                    italic: font_style.italic,
-                    underline: font_style.underline,
-                    font: font_style.typeface.decode(encoding),
-                    fg: font_style.fg,
-                    bg: font_style.bg,
-                    size: (font_style.size as i32) * 4 / 3,
-                });
-        let cell_style = self.v3.style_pair.cell_style.as_ref().map(|cell_style| {
-            pivot::CellStyle {
-                horz_align: match cell_style.halign {
-                    0 => Some(HorzAlign::Center),
-                    2 => Some(HorzAlign::Left),
-                    4 => Some(HorzAlign::Right),
-                    6 => Some(HorzAlign::Decimal {
-                        offset: cell_style.decimal_offset,
-                        decimal: Decimal::Dot, /*XXX*/
-                    }),
-                    _ => None,
-                },
-                vert_align: match cell_style.valign {
-                    0 => VertAlign::Middle,
-                    3 => VertAlign::Bottom,
-                    _ => VertAlign::Top,
-                },
-                margins: enum_map! {
-                    Axis2::X => [cell_style.left_margin as i32, cell_style.right_margin as i32],
-                    Axis2::Y => [cell_style.top_margin as i32, cell_style.bottom_margin as i32],
-                },
-            }
-        });
-        ValueStyle {
-            cell_style,
-            font_style,
-            subscripts: self.subscripts.iter().map(|s| s.decode(encoding)).collect(),
-            footnotes: self
-                .refs
-                .iter()
-                .flat_map(|index| footnotes.get(*index as usize))
-                .cloned()
-                .collect(),
-        }
-    }
-    fn decode_optional(
-        mods: &Option<Self>,
-        encoding: &'static Encoding,
-        footnotes: &pivot::Footnotes,
-    ) -> Option<Box<pivot::ValueStyle>> {
-        mods.as_ref()
-            .map(|mods| Box::new(mods.decode(encoding, footnotes)))
-    }
-    fn template_id(&self, encoding: &'static Encoding) -> Option<String> {
-        self.v3
-            .template_string
-            .as_ref()
-            .and_then(|template_string| template_string.id.as_ref())
-            .map(|s| s.decode(encoding))
-    }
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct TemplateString {
-    #[br(parse_with(parse_counted), temp)]
-    _sponge: Sponge,
-    #[br(parse_with(parse_explicit_optional))]
-    id: Option<U32String>,
-}
-
-#[derive(Debug, Default)]
-struct Sponge;
-
-impl BinRead for Sponge {
-    type Args<'a> = ();
-
-    fn read_options<R: Read + Seek>(reader: &mut R, _endian: Endian, _args: ()) -> BinResult<Self> {
-        let mut buf = [0; 32];
-        while reader.read(&mut buf)? > 0 {}
-        Ok(Self)
-    }
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug, Default)]
-struct StylePair {
-    #[br(parse_with(parse_explicit_optional))]
-    font_style: Option<FontStyle>,
-    #[br(parse_with(parse_explicit_optional))]
-    cell_style: Option<CellStyle>,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct FontStyle {
-    #[br(parse_with(parse_bool))]
-    bold: bool,
-    #[br(parse_with(parse_bool))]
-    italic: bool,
-    #[br(parse_with(parse_bool))]
-    underline: bool,
-    #[br(parse_with(parse_bool))]
-    show: bool,
-    #[br(parse_with(parse_color))]
-    fg: Color,
-    #[br(parse_with(parse_color))]
-    bg: Color,
-    typeface: U32String,
-    size: u8,
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct CellStyle {
-    halign: i32,
-    valign: i32,
-    decimal_offset: f64,
-    left_margin: i16,
-    right_margin: i16,
-    top_margin: i16,
-    bottom_margin: i16,
-}
-
-#[binread]
-#[br(little)]
-#[br(import(version: Version))]
-#[derive(Debug)]
-struct Dimension {
-    #[br(args(version))]
-    name: Value,
-    #[br(temp)]
-    _x1: u8,
-    #[br(temp)]
-    _x2: u8,
-    #[br(temp)]
-    _x3: u32,
-    #[br(parse_with(parse_bool))]
-    hide_dim_label: bool,
-    #[br(parse_with(parse_bool))]
-    hide_all_labels: bool,
-    #[br(magic(1u8), temp)]
-    _dim_index: i32,
-    #[br(parse_with(parse_vec), args(version))]
-    categories: Vec<Category>,
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct Category {
-    #[br(args(version))]
-    name: Value,
-    #[br(args(version))]
-    child: Child,
-}
-
-impl Category {
-    fn decode(&self, encoding: &'static Encoding, footnotes: &Footnotes, group: &mut pivot::Group) {
-        let name = self.name.decode(encoding, footnotes);
-        match &self.child {
-            Child::Leaf { leaf_index: _ } => {
-                group.push(pivot::Leaf::new(name));
-            }
-            Child::Group {
-                merge: true,
-                subcategories,
-            } => {
-                for subcategory in subcategories {
-                    subcategory.decode(encoding, footnotes, group);
-                }
-            }
-            Child::Group {
-                merge: false,
-                subcategories,
-            } => {
-                let mut subgroup = Group::new(name).with_label_shown();
-                for subcategory in subcategories {
-                    subcategory.decode(encoding, footnotes, &mut subgroup);
-                }
-                group.push(subgroup);
-            }
-        }
-    }
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-enum Child {
-    Leaf {
-        #[br(magic(0u16), parse_with(parse_bool), temp)]
-        _x24: bool,
-        #[br(magic(b"\x02\0\0\0"))]
-        leaf_index: u32,
-        #[br(magic(0u32), temp)]
-        _tail: (),
-    },
-    Group {
-        #[br(parse_with(parse_bool))]
-        merge: bool,
-        #[br(temp, magic(b"\0\x01"))]
-        _x23: i32,
-        #[br(magic(-1i32), parse_with(parse_vec), args(version))]
-        subcategories: Vec<Box<Category>>,
-    },
-}
-
-#[binread]
-#[br(little)]
-#[derive(Debug)]
-struct Axes {
-    #[br(temp)]
-    n_layers: u32,
-    #[br(temp)]
-    n_rows: u32,
-    #[br(temp)]
-    n_columns: u32,
-    #[br(count(n_layers))]
-    layers: Vec<u32>,
-    #[br(count(n_rows))]
-    rows: Vec<u32>,
-    #[br(count(n_columns))]
-    columns: Vec<u32>,
-}
-
-impl Axes {
-    fn decode(
-        &self,
-        dimensions: Vec<pivot::Dimension>,
-    ) -> Result<Vec<(Axis3, pivot::Dimension)>, LightError> {
-        let n = self.layers.len() + self.rows.len() + self.columns.len();
-        if n != dimensions.len() {
-            /* XXX warn
-                return Err(LightError::WrongAxisCount {
-                    expected: dimensions.len(),
-                    actual: n,
-                    n_layers: self.layers.len(),
-                    n_rows: self.rows.len(),
-                    n_columns: self.columns.len(),
-            });*/
-            return Ok(dimensions
-                .into_iter()
-                .map(|dimension| (Axis3::Y, dimension))
-                .collect());
-        }
-
-        fn axis_dims(axis: Axis3, dimensions: &[u32]) -> impl Iterator<Item = (Axis3, usize)> {
-            dimensions.iter().map(move |d| (axis, *d as usize))
-        }
-
-        let mut axes = vec![None; n];
-        for (axis, index) in axis_dims(Axis3::Z, &self.layers)
-            .chain(axis_dims(Axis3::Y, &self.rows))
-            .chain(axis_dims(Axis3::X, &self.columns))
-        {
-            if index >= n {
-                return Err(LightError::InvalidDimensionIndex { index, n });
-            } else if axes[index].is_some() {
-                return Err(LightError::DuplicateDimensionIndex(index));
-            }
-            axes[index] = Some(axis);
-        }
-        Ok(axes
-            .into_iter()
-            .map(|axis| axis.unwrap())
-            .zip(dimensions)
-            .collect())
-    }
-}
-
-#[binread]
-#[br(little, import(version: Version))]
-#[derive(Debug)]
-struct Cell {
-    index: u64,
-    #[br(if(version == Version::V1), temp)]
-    _zero: Optional<Zero>,
-    #[br(args(version))]
-    value: Value,
-}
index ff7e440d8f837587a0cc0ea7de781ecb581e63fa..4cc7bf06ca1063389b3d224bd7af94b646727d8c 100644 (file)
@@ -35,10 +35,7 @@ use std::{
 use enum_map::{EnumMap, enum_map};
 use ndarray::{Array, Array2};
 
-use crate::output::{
-    pivot::{CellStyle, DisplayValue, FontStyle, Footnote, HorzAlign, ValueInner},
-    spv::html,
-};
+use crate::{output::pivot::{CellStyle, DisplayValue, FontStyle, Footnote, HorzAlign, ValueInner}, spv::read::html};
 
 use super::pivot::{
     Area, AreaStyle, Axis2, Border, BorderStyle, HeadingRegion, Value, ValueOptions,
index 09d05aa05d8861e63714550cab9701e10278691d..f6bee721db9935a5fb7526e50ffa897d4ef2057d 100644 (file)
@@ -27,4 +27,5 @@
 
 pub use write::Writer;
 
+pub mod read;
 mod write;
diff --git a/rust/pspp/src/spv/read.rs b/rust/pspp/src/spv/read.rs
new file mode 100644 (file)
index 0000000..8b0c2dc
--- /dev/null
@@ -0,0 +1,780 @@
+// 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/>.
+
+use std::{
+    fs::File,
+    io::{BufReader, Cursor, Read, Seek},
+    path::Path,
+};
+
+use anyhow::{Context, anyhow};
+use binrw::{BinRead, error::ContextExt};
+use cairo::ImageSurface;
+use displaydoc::Display;
+use paper_sizes::PaperSize;
+use serde::Deserialize;
+use zip::{ZipArchive, result::ZipError};
+
+use crate::{
+    crypto::EncryptedFile,
+    output::{
+        Details, Item, SpvInfo, SpvMembers, Text,
+        page::{self},
+        pivot::{Axis2, Length, Look, TableProperties, Value},
+    },
+    spv::read::{
+        html::Document,
+        legacy_bin::LegacyBin,
+        legacy_xml::Visualization,
+        light::{LightError, LightTable},
+    },
+};
+
+mod css;
+pub mod html;
+mod legacy_bin;
+mod legacy_xml;
+mod light;
+
+/// Options for reading an SPV file.
+#[derive(Clone, Debug, Default)]
+pub struct ReadOptions {
+    /// Password to use to unlock an encrypted SPV file.
+    ///
+    /// For an encrypted SPV file, this must be set to the (encoded or
+    /// unencoded) password.
+    ///
+    /// For a plaintext SPV file, this must be None.
+    pub password: Option<String>,
+}
+
+impl ReadOptions {
+    /// Construct a new [ReadOptions] without a password.
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Causes the file to be read by decrypting it with the given `password` or
+    /// without decrypting if `password` is None.
+    pub fn with_password(self, password: Option<String>) -> Self {
+        Self { password }
+    }
+
+    /// Opens the file at `path`.
+    pub fn open_file<P>(mut self, path: P) -> Result<SpvFile, anyhow::Error>
+    where
+        P: AsRef<Path>,
+    {
+        let file = File::open(path)?;
+        if let Some(password) = self.password.take() {
+            self.open_reader_encrypted(file, password)
+        } else {
+            Self::open_reader_inner(file)
+        }
+    }
+
+    /// Opens the file read from `reader`.
+    fn open_reader_encrypted<R>(self, reader: R, password: String) -> Result<SpvFile, anyhow::Error>
+    where
+        R: Read + Seek + 'static,
+    {
+        Self::open_reader_inner(
+            EncryptedFile::new(reader)?
+                .unlock(password.as_bytes())
+                .map_err(|_| anyhow!("Incorrect password."))?,
+        )
+    }
+
+    /// Opens the file read from `reader`.
+    pub fn open_reader<R>(mut self, reader: R) -> Result<SpvFile, anyhow::Error>
+    where
+        R: Read + Seek + 'static,
+    {
+        if let Some(password) = self.password.take() {
+            self.open_reader_encrypted(reader, password)
+        } else {
+            Self::open_reader_inner(reader)
+        }
+    }
+
+    fn open_reader_inner<R>(reader: R) -> Result<SpvFile, anyhow::Error>
+    where
+        R: Read + Seek + 'static,
+    {
+        // Open archive.
+        let mut archive = ZipArchive::new(reader).map_err(|error| match error {
+            ZipError::InvalidArchive(_) => Error::NotSpv,
+            other => other.into(),
+        })?;
+        Ok(Self::from_spv_zip_archive(&mut archive)?)
+    }
+
+    fn from_spv_zip_archive<R>(archive: &mut ZipArchive<R>) -> Result<SpvFile, Error>
+    where
+        R: Read + Seek,
+    {
+        // Check manifest.
+        let mut file = archive
+            .by_name("META-INF/MANIFEST.MF")
+            .map_err(|_| Error::NotSpv)?;
+        let mut string = String::new();
+        file.read_to_string(&mut string)?;
+        if string.trim() != "allowPivoting=true" {
+            return Err(Error::NotSpv);
+        }
+        drop(file);
+
+        let mut items = Vec::new();
+        let mut page_setup = None;
+        for i in 0..archive.len() {
+            let name = String::from(archive.name_for_index(i).unwrap());
+            if name.starts_with("outputViewer") && name.ends_with(".xml") {
+                let (mut new_items, ps) = read_heading(archive, i, &name)?;
+                items.append(&mut new_items);
+                page_setup = page_setup.or(ps);
+            }
+        }
+
+        Ok(SpvFile {
+            items: items.into_iter().collect(),
+            page_setup,
+        })
+    }
+}
+
+/// A SPSS viewer (SPV) file read with [ReadOptions].
+pub struct SpvFile {
+    /// SPV file contents.
+    pub items: Vec<Item>,
+
+    /// The page setup in the SPV file, if any.
+    pub page_setup: Option<page::PageSetup>,
+}
+
+impl SpvFile {
+    // Returns the individual parts of the `SpvFile`.
+    pub fn into_parts(self) -> (Vec<Item>, Option<page::PageSetup>) {
+        (self.items, self.page_setup)
+    }
+
+    /// Returns just the [Item]s.
+    pub fn into_items(self) -> Vec<Item> {
+        self.items
+    }
+}
+
+/// An error reading an SPV file.
+///
+/// Returned by [ReadOptions::open_file] and [ReadOptions::open_reader].
+#[derive(Debug, Display, thiserror::Error)]
+pub enum Error {
+    /// Not an SPV file.
+    NotSpv,
+
+    /// {0}
+    ZipError(#[from] ZipError),
+
+    /// {0}
+    IoError(#[from] std::io::Error),
+
+    /// {0}
+    DeError(#[from] quick_xml::DeError),
+
+    /// {0}
+    BinrwError(#[from] binrw::Error),
+
+    /// {0}
+    LightError(#[from] LightError),
+
+    /// {0}
+    CairoError(#[from] cairo::IoError),
+}
+
+fn new_error_item(message: impl Into<Value>) -> Item {
+    Text::new_log(message).into_item().with_label("Error")
+}
+
+fn read_heading<R>(
+    archive: &mut ZipArchive<R>,
+    file_number: usize,
+    structure_member: &str,
+) -> Result<(Vec<Item>, Option<page::PageSetup>), Error>
+where
+    R: Read + Seek,
+{
+    let member = BufReader::new(archive.by_index(file_number)?);
+    let mut heading: Heading = match serde_path_to_error::deserialize(
+        &mut quick_xml::de::Deserializer::from_reader(member),
+    )
+    .with_context(|| format!("Failed to parse {structure_member}"))
+    {
+        Ok(result) => result,
+        Err(error) => panic!("{error:?}"),
+    };
+    let page_setup = heading.page_setup.take().map(|ps| ps.decode());
+    Ok((heading.decode(archive, structure_member)?, page_setup))
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Heading {
+    #[serde(rename = "@visibility")]
+    visibility: Option<String>,
+    #[serde(rename = "@commandName")]
+    command_name: Option<String>,
+    label: Label,
+    page_setup: Option<PageSetup>,
+
+    #[serde(rename = "$value")]
+    #[serde(default)]
+    children: Vec<HeadingContent>,
+}
+
+impl Heading {
+    fn decode<R>(
+        self,
+        archive: &mut ZipArchive<R>,
+        structure_member: &str,
+    ) -> Result<Vec<Item>, Error>
+    where
+        R: Read + Seek,
+    {
+        let mut items = Vec::new();
+        for child in self.children {
+            match child {
+                HeadingContent::Container(container) => {
+                    if container.page_break_before == PageBreakBefore::Always {
+                        items.push(
+                            Details::PageBreak
+                                .into_item()
+                                .with_spv_info(SpvInfo::new(structure_member)),
+                        );
+                    }
+                    let item = match container.content {
+                        ContainerContent::Table(table) => {
+                            table.decode(archive, structure_member).unwrap() /* XXX*/
+                        }
+                        ContainerContent::Graph(graph) => graph.decode(structure_member),
+                        ContainerContent::Text(container_text) => Text::new(
+                            match container_text.text_type {
+                                TextType::Title => crate::output::TextType::Title,
+                                TextType::Log | TextType::Text => crate::output::TextType::Log,
+                                TextType::PageTitle => crate::output::TextType::PageTitle,
+                            },
+                            container_text.decode(),
+                        )
+                        .into_item()
+                        .with_command_name(container_text.command_name)
+                        .with_spv_info(SpvInfo::new(structure_member)),
+                        ContainerContent::Image(image) => {
+                            image.decode(archive, structure_member).unwrap()
+                        } /*XXX*/,
+                        ContainerContent::Object(object) => {
+                            object.decode(archive, structure_member).unwrap()
+                        } /*XXX*/,
+                        ContainerContent::Model => new_error_item("models not yet implemented")
+                            .with_spv_info(SpvInfo::new(structure_member).with_error()),
+                        ContainerContent::Tree => new_error_item("trees not yet implemented")
+                            .with_spv_info(SpvInfo::new(structure_member).with_error()),
+                    };
+                    items.push(item.with_show(container.visibility == Visibility::Visible));
+                }
+                HeadingContent::Heading(mut heading) => {
+                    let show = !heading.visibility.is_some();
+                    let label = std::mem::take(&mut heading.label.text);
+                    let command_name = heading.command_name.take();
+                    items.push(
+                        heading
+                            .decode(archive, structure_member)?
+                            .into_iter()
+                            .collect::<Item>()
+                            .with_show(show)
+                            .with_label(label)
+                            .with_command_name(command_name)
+                            .with_spv_info(SpvInfo::new(structure_member)),
+                    );
+                }
+            }
+        }
+        Ok(items)
+    }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct PageSetup {
+    #[serde(rename = "@initial-page-number")]
+    pub initial_page_number: Option<i32>,
+    #[serde(rename = "@chart-size")]
+    pub chart_size: Option<ChartSize>,
+    #[serde(rename = "@margin-left")]
+    pub margin_left: Option<Length>,
+    #[serde(rename = "@margin-right")]
+    pub margin_right: Option<Length>,
+    #[serde(rename = "@margin-top")]
+    pub margin_top: Option<Length>,
+    #[serde(rename = "@margin-bottom")]
+    pub margin_bottom: Option<Length>,
+    #[serde(rename = "@paper-height")]
+    pub paper_height: Option<Length>,
+    #[serde(rename = "@paper-width")]
+    pub paper_width: Option<Length>,
+    #[serde(rename = "@reference-orientation")]
+    pub reference_orientation: Option<ReferenceOrientation>,
+    #[serde(rename = "@space-after")]
+    pub space_after: Option<Length>,
+    pub page_header: PageHeader,
+    pub page_footer: PageFooter,
+}
+
+impl PageSetup {
+    fn decode(&self) -> page::PageSetup {
+        let mut setup = page::PageSetup::default();
+        if let Some(initial_page_number) = self.initial_page_number {
+            setup.initial_page_number = initial_page_number;
+        }
+        if let Some(chart_size) = self.chart_size {
+            setup.chart_size = chart_size.into();
+        }
+        if let Some(margin_left) = self.margin_left {
+            setup.margins.0[Axis2::X][0] = margin_left.into();
+        }
+        if let Some(margin_right) = self.margin_right {
+            setup.margins.0[Axis2::X][1] = margin_right.into();
+        }
+        if let Some(margin_top) = self.margin_top {
+            setup.margins.0[Axis2::Y][0] = margin_top.into();
+        }
+        if let Some(margin_bottom) = self.margin_bottom {
+            setup.margins.0[Axis2::Y][1] = margin_bottom.into();
+        }
+        match (self.paper_width, self.paper_height) {
+            (Some(width), Some(height)) => {
+                setup.paper = PaperSize::new(width.0, height.0, paper_sizes::Unit::Inch)
+            }
+            (Some(length), None) | (None, Some(length)) => {
+                setup.paper = PaperSize::new(length.0, length.0, paper_sizes::Unit::Inch)
+            }
+            (None, None) => (),
+        }
+        if let Some(reference_orientation) = self.reference_orientation {
+            setup.orientation = reference_orientation.into();
+        }
+        if let Some(space_after) = self.space_after {
+            setup.object_spacing = space_after.into();
+        }
+        if let Some(PageParagraph { text }) = &self.page_header.page_paragraph {
+            setup.header = text.decode();
+        }
+        if let Some(PageParagraph { text }) = &self.page_footer.page_paragraph {
+            setup.footer = text.decode();
+        }
+        setup
+    }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct PageHeader {
+    page_paragraph: Option<PageParagraph>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct PageFooter {
+    page_paragraph: Option<PageParagraph>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct PageParagraph {
+    text: PageParagraphText,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct PageParagraphText {
+    #[serde(default, rename = "$text")]
+    text: String,
+}
+
+impl PageParagraphText {
+    fn decode(&self) -> Document {
+        Document::from_html(&self.text)
+    }
+}
+
+#[derive(Copy, Clone, Debug, Default, Deserialize)]
+#[serde(rename = "snake_case")]
+enum ReferenceOrientation {
+    #[serde(alias = "0")]
+    #[serde(alias = "0deg")]
+    #[serde(alias = "inherit")]
+    #[default]
+    Portrait,
+
+    #[serde(alias = "90")]
+    #[serde(alias = "90deg")]
+    #[serde(alias = "-270")]
+    #[serde(alias = "-270deg")]
+    Landscape,
+
+    #[serde(alias = "180")]
+    #[serde(alias = "180deg")]
+    #[serde(alias = "-1280")]
+    #[serde(alias = "-180deg")]
+    ReversePortrait,
+
+    #[serde(alias = "270")]
+    #[serde(alias = "270deg")]
+    #[serde(alias = "-90")]
+    #[serde(alias = "-90deg")]
+    Seascape,
+}
+
+impl From<ReferenceOrientation> for page::Orientation {
+    fn from(value: ReferenceOrientation) -> Self {
+        match value {
+            ReferenceOrientation::Portrait | ReferenceOrientation::ReversePortrait => {
+                page::Orientation::Portrait
+            }
+            ReferenceOrientation::Landscape | ReferenceOrientation::Seascape => {
+                page::Orientation::Landscape
+            }
+        }
+    }
+}
+
+/// Chart size.
+#[derive(Copy, Clone, Debug, Default, Deserialize)]
+enum ChartSize {
+    #[default]
+    #[serde(rename = "as-is")]
+    AsIs,
+
+    #[serde(rename = "full-height")]
+    FullHeight,
+
+    #[serde(rename = "half-height")]
+    HalfHeight,
+
+    #[serde(rename = "quarter-height")]
+    QuarterHeight,
+}
+
+impl From<ChartSize> for page::ChartSize {
+    fn from(value: ChartSize) -> Self {
+        match value {
+            ChartSize::AsIs => page::ChartSize::AsIs,
+            ChartSize::FullHeight => page::ChartSize::FullHeight,
+            ChartSize::HalfHeight => page::ChartSize::HalfHeight,
+            ChartSize::QuarterHeight => page::ChartSize::QuarterHeight,
+        }
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum HeadingContent {
+    Container(Container),
+    Heading(Box<Heading>),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Label {
+    #[serde(default, rename = "$text")]
+    text: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Container {
+    #[serde(default, rename = "@visibility")]
+    visibility: Visibility,
+    #[serde(rename = "@page-break-before")]
+    #[serde(default)]
+    page_break_before: PageBreakBefore,
+    #[serde(rename = "@text-align")]
+    text_align: Option<TextAlign>,
+    #[serde(rename = "@width")]
+    width: Option<String>,
+    label: Label,
+
+    #[serde(rename = "$value")]
+    content: ContainerContent,
+}
+
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum PageBreakBefore {
+    #[default]
+    Auto,
+    Always,
+    Avoid,
+    Left,
+    Right,
+    Inherit,
+}
+
+#[derive(Deserialize, Debug, Default, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+enum Visibility {
+    #[default]
+    Visible,
+    Hidden,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum TextAlign {
+    Left,
+    Center,
+    Right,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum ContainerContent {
+    Table(Table),
+    Text(ContainerText),
+    Graph(Graph),
+    Model,
+    Object(Object),
+    Image(Image),
+    Tree,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Graph {
+    #[serde(rename = "@commandName")]
+    command_name: String,
+    data_path: Option<String>,
+    path: String,
+    csv_path: Option<String>,
+}
+
+impl Graph {
+    fn decode(&self, structure_member: &str) -> Item {
+        crate::output::Chart
+            .into_item()
+            .with_spv_info(
+                SpvInfo::new(structure_member).with_members(SpvMembers::Graph {
+                    data: self.data_path.clone(),
+                    xml: self.path.clone(),
+                    csv: self.csv_path.clone(),
+                }),
+            )
+    }
+}
+
+fn decode_image<R>(
+    archive: &mut ZipArchive<R>,
+    structure_member: &str,
+    command_name: &Option<String>,
+    image_name: &str,
+) -> Result<Item, Error>
+where
+    R: Read + Seek,
+{
+    let mut png = archive.by_name(image_name)?;
+    let image = ImageSurface::create_from_png(&mut png)?;
+    Ok(Details::Image(image)
+        .into_item()
+        .with_command_name(command_name.clone())
+        .with_spv_info(
+            SpvInfo::new(structure_member).with_members(SpvMembers::Image(image_name.into())),
+        ))
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Image {
+    #[serde(rename = "@commandName")]
+    command_name: Option<String>,
+    data_path: String,
+}
+
+impl Image {
+    fn decode<R>(&self, archive: &mut ZipArchive<R>, structure_member: &str) -> Result<Item, Error>
+    where
+        R: Read + Seek,
+    {
+        decode_image(
+            archive,
+            structure_member,
+            &self.command_name,
+            &self.data_path,
+        )
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Object {
+    #[serde(rename = "@commandName")]
+    command_name: Option<String>,
+    #[serde(rename = "@uri")]
+    uri: String,
+}
+
+impl Object {
+    fn decode<R>(&self, archive: &mut ZipArchive<R>, structure_member: &str) -> Result<Item, Error>
+    where
+        R: Read + Seek,
+    {
+        decode_image(archive, structure_member, &self.command_name, &self.uri)
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Table {
+    #[serde(rename = "@commandName")]
+    command_name: String,
+    #[serde(rename = "@subType")]
+    sub_type: String,
+    #[serde(rename = "@tableId")]
+    table_id: Option<i64>,
+    #[serde(rename = "@type")]
+    table_type: TableType,
+    properties: Option<TableProperties>,
+    table_structure: TableStructure,
+}
+
+impl Table {
+    fn decode<R>(&self, archive: &mut ZipArchive<R>, structure_member: &str) -> Result<Item, Error>
+    where
+        R: Read + Seek,
+    {
+        match &self.table_structure.path {
+            None => {
+                let member_name = &self.table_structure.data_path;
+                let mut light = archive.by_name(member_name)?;
+                let mut data = Vec::with_capacity(light.size() as usize);
+                light.read_to_end(&mut data)?;
+                let mut cursor = Cursor::new(data);
+                let table = LightTable::read(&mut cursor).map_err(|e| {
+                    e.with_message(format!(
+                        "While parsing {member_name:?} as light binary SPV member"
+                    ))
+                })?;
+                let pivot_table = table.decode()?;
+                Ok(pivot_table.into_item().with_spv_info(
+                    SpvInfo::new(structure_member)
+                        .with_members(SpvMembers::Light(self.table_structure.data_path.clone())),
+                ))
+            }
+            Some(xml_member_name) => {
+                let bin_member_name = &self.table_structure.data_path;
+                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 legacy binary SPV member"
+                    ))
+                })?;
+                let data = legacy_bin.decode();
+                drop(bin_member);
+
+                let member = BufReader::new(archive.by_name(&xml_member_name)?);
+                let visualization: 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,
+                    self.properties
+                        .as_ref()
+                        .map_or_else(Look::default, |properties| properties.clone().into()),
+                )?;
+
+                Ok(pivot_table.into_item().with_spv_info(
+                    SpvInfo::new(structure_member).with_members(SpvMembers::Legacy {
+                        xml: xml_member_name.clone(),
+                        binary: bin_member_name.clone(),
+                    }),
+                ))
+            }
+        }
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum TableType {
+    Table,
+    Note,
+    Warning,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct ContainerText {
+    #[serde(rename = "@type")]
+    text_type: TextType,
+    #[serde(rename = "@commandName")]
+    command_name: Option<String>,
+    html: String,
+}
+
+impl ContainerText {
+    fn decode(&self) -> Value {
+        html::Document::from_html(&self.html).into_value()
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum TextType {
+    Title,
+    Log,
+    Text,
+    #[serde(rename = "page-title")]
+    PageTitle,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct TableStructure {
+    /// The `.xml` member name, for legacy members only.
+    path: Option<String>,
+    /// The `.bin` member name.
+    data_path: String,
+    /// Rarely used, not understood.
+    #[serde(rename = "csvPath")]
+    _csv_path: Option<String>,
+}
+
+#[cfg(test)]
+#[test]
+fn test_spv() {
+    let items = ReadOptions::new()
+        .open_file("/home/blp/pspp/rust/tests/utilities/regress.spv")
+        .unwrap()
+        .into_items();
+    for item in items {
+        println!("{item}");
+    }
+    todo!()
+}
diff --git a/rust/pspp/src/spv/read/css.rs b/rust/pspp/src/spv/read/css.rs
new file mode 100644 (file)
index 0000000..5733b28
--- /dev/null
@@ -0,0 +1,376 @@
+use std::{
+    borrow::Cow,
+    fmt::{Display, Write},
+    mem::discriminant,
+    ops::Not,
+};
+
+use itertools::Itertools;
+
+use crate::{
+    output::pivot::{FontStyle, HorzAlign},
+    spv::read::html::Style,
+};
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum Token<'a> {
+    Id(Cow<'a, str>),
+    LeftCurly,
+    RightCurly,
+    Colon,
+    Semicolon,
+}
+
+struct Lexer<'a>(&'a str);
+
+impl<'a> Iterator for Lexer<'a> {
+    type Item = Token<'a>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        let mut s = self.0;
+        loop {
+            s = s.trim_start();
+            if let Some(rest) = s.strip_prefix("<!--") {
+                s = rest;
+            } else if let Some(rest) = s.strip_prefix("-->") {
+                s = rest;
+            } else {
+                break;
+            }
+        }
+        let mut iter = s.chars();
+        let (c, mut rest) = (iter.next()?, iter.as_str());
+        let (token, rest) = match c {
+            '{' => (Token::LeftCurly, rest),
+            '}' => (Token::RightCurly, rest),
+            ':' => (Token::Colon, rest),
+            ';' => (Token::Semicolon, rest),
+            '\'' | '"' => {
+                let quote = c;
+                let mut s = String::new();
+                while let Some(c) = iter.next() {
+                    if c == quote {
+                        break;
+                    } else if c != '\\' {
+                        s.push(c);
+                    } else {
+                        let start = iter.as_str();
+                        match iter.next() {
+                            None => break,
+                            Some(a) if a.is_ascii_alphanumeric() => {
+                                let n = start
+                                    .chars()
+                                    .take_while(|c| c.is_ascii_alphanumeric())
+                                    .take(6)
+                                    .count();
+                                iter = start[n..].chars();
+                                if let Ok(code_point) = u32::from_str_radix(&start[..n], 16)
+                                    && let Ok(c) = char::try_from(code_point)
+                                {
+                                    s.push(c);
+                                }
+                            }
+                            Some('\n') => (),
+                            Some(other) => s.push(other),
+                        }
+                    }
+                }
+                (Token::Id(Cow::from(s)), iter.as_str())
+            }
+            _ => {
+                while !iter.as_str().starts_with("-->")
+                    && let Some(c) = iter.next()
+                    && !c.is_whitespace()
+                    && c != '{'
+                    && c != '}'
+                    && c != ':'
+                    && c != ';'
+                {
+                    rest = iter.as_str();
+                }
+                let id_len = s.len() - rest.len();
+                let (id, rest) = s.split_at(id_len);
+                (Token::Id(Cow::from(id)), rest)
+            }
+        };
+        self.0 = rest;
+        Some(token)
+    }
+}
+
+impl HorzAlign {
+    pub fn from_css(s: &str) -> Option<Self> {
+        let mut lexer = Lexer(s);
+        while let Some(token) = lexer.next() {
+            if let Token::Id(key) = token
+                && let Some(Token::Colon) = lexer.next()
+                && let Some(Token::Id(value)) = lexer.next()
+                && key.as_ref() == "text-align"
+                && let Ok(align) = value.parse()
+            {
+                return Some(align);
+            }
+        }
+        None
+    }
+}
+
+impl Style {
+    pub fn parse_css(styles: &mut Vec<Style>, s: &str) {
+        let mut lexer = Lexer(s);
+        while let Some(token) = lexer.next() {
+            if let Token::Id(key) = token
+                && let Some(Token::Colon) = lexer.next()
+                && let Some(Token::Id(value)) = lexer.next()
+                && let Some((style, add)) = match key.as_ref() {
+                    "color" => value.parse().ok().map(|color| (Style::Color(color), true)),
+                    "font-weight" => Some((Style::Bold, value == "bold")),
+                    "font-style" => Some((Style::Italic, value == "italic")),
+                    "text-decoration" => Some((Style::Underline, value == "underline")),
+                    "font-family" => Some((Style::Face(value.into()), true)),
+                    "font-size" => value
+                        .parse::<i32>()
+                        .ok()
+                        .map(|size| (Style::Size(size as f64 * 0.75), true)),
+                    _ => None,
+                }
+            {
+                // Remove from `styles` any style of the same kind as `style`.
+                styles.retain(|s| discriminant(s) != discriminant(&style));
+                if add {
+                    styles.push(style);
+                }
+            }
+        }
+    }
+}
+
+impl FontStyle {
+    pub fn parse_css(&mut self, s: &str) {
+        let mut lexer = Lexer(s);
+        while let Some(token) = lexer.next() {
+            if let Token::Id(key) = token
+                && let Some(Token::Colon) = lexer.next()
+                && let Some(Token::Id(value)) = lexer.next()
+            {
+                match key.as_ref() {
+                    "color" => {
+                        if let Ok(color) = value.parse() {
+                            self.fg = color;
+                        }
+                    }
+                    "font-weight" => self.bold = value == "bold",
+                    "font-style" => self.italic = value == "italic",
+                    "text-decoration" => self.underline = value == "underline",
+                    "font-family" => self.font = value.into(),
+                    "font-size" => {
+                        if let Ok(size) = value.parse::<i32>() {
+                            self.size = (size as i64 * 3 / 4) as i32;
+                        }
+                    }
+                    _ => (),
+                }
+            }
+        }
+    }
+
+    pub fn from_css(s: &str) -> Self {
+        let mut style = FontStyle::default();
+        style.parse_css(s);
+        style
+    }
+
+    pub fn to_css(&self, base: &FontStyle) -> Option<String> {
+        let mut settings = Vec::new();
+        if self.font != base.font {
+            if is_css_ident(&self.font) {
+                settings.push(format!("font-family: {}", &self.font));
+            } else {
+                settings.push(format!("font-family: {}", CssString(&self.font)));
+            }
+        }
+        if self.bold != base.bold {
+            settings.push(format!(
+                "font-weight: {}",
+                if self.bold { "bold" } else { "normal" }
+            ));
+        }
+        if self.italic != base.italic {
+            settings.push(format!(
+                "font-style: {}",
+                if self.bold { "italic" } else { "normal" }
+            ));
+        }
+        if self.underline != base.underline {
+            settings.push(format!(
+                "text-decoration: {}",
+                if self.bold { "underline" } else { "none" }
+            ));
+        }
+        if self.size != base.size {
+            settings.push(format!("font-size: {}", self.size as i64 * 4 / 3));
+        }
+        if self.fg != base.fg {
+            settings.push(format!("color: {}", self.fg.display_css()));
+        }
+        settings
+            .is_empty()
+            .not()
+            .then(|| format!("<!-- p {{ {} }} -->", settings.into_iter().join("; ")))
+    }
+}
+
+fn is_css_ident(s: &str) -> bool {
+    fn is_nmstart(c: char) -> bool {
+        c.is_ascii_alphabetic() || c == '_'
+    }
+    s.chars().next().is_some_and(is_nmstart) && s.chars().all(|c| is_nmstart(c) || c as u32 > 159)
+}
+
+struct CssString<'a>(&'a str);
+
+impl<'a> Display for CssString<'a> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let quote = if self.0.contains('"') && !self.0.contains('\'') {
+            '\''
+        } else {
+            '"'
+        };
+        f.write_char(quote)?;
+        for c in self.0.chars() {
+            match c {
+                _ if c == quote || c == '\\' => {
+                    f.write_char('\\')?;
+                    f.write_char(c)?;
+                }
+                '\n' => f.write_str("\\00000a")?,
+                c => f.write_char(c)?,
+            }
+        }
+        f.write_char(quote)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::borrow::Cow;
+
+    use crate::{
+        output::pivot::{Color, FontStyle, HorzAlign},
+        spv::read::css::{Lexer, Token},
+    };
+
+    #[test]
+    fn css_horz_align() {
+        assert_eq!(
+            HorzAlign::from_css("text-align: left"),
+            Some(HorzAlign::Left)
+        );
+        assert_eq!(
+            HorzAlign::from_css("margin-top: 0; text-align:center"),
+            Some(HorzAlign::Center)
+        );
+        assert_eq!(
+            HorzAlign::from_css("text-align: Right; margin-top:0"),
+            Some(HorzAlign::Right)
+        );
+        assert_eq!(HorzAlign::from_css("text-align: other"), None);
+        assert_eq!(HorzAlign::from_css("margin-top: 0"), None);
+    }
+
+    #[test]
+    fn css_strings() {
+        #[track_caller]
+        fn test_string(css: &str, value: &str) {
+            let mut lexer = Lexer(css);
+            assert_eq!(lexer.next(), Some(Token::Id(Cow::from(value))));
+            assert_eq!(lexer.next(), None);
+        }
+
+        test_string(r#""abc""#, "abc");
+        test_string(r#""a\"'\'bc""#, "a\"''bc");
+        test_string(r#""a\22 bc""#, "a\" bc");
+        test_string(r#""a\000022bc""#, "a\"bc");
+        test_string(r#""a'bc""#, "a'bc");
+        test_string(
+            r#""\\\
+xyzzy""#,
+            "\\xyzzy",
+        );
+
+        test_string(r#"'abc'"#, "abc");
+        test_string(r#"'a"\"\'bc'"#, "a\"\"'bc");
+        test_string(r#"'a\22 bc'"#, "a\" bc");
+        test_string(r#"'a\000022bc'"#, "a\"bc");
+        test_string(r#"'a\'bc'"#, "a'bc");
+        test_string(
+            r#"'a\'bc\
+xyz'"#,
+            "a'bcxyz",
+        );
+        test_string(r#"'\\'"#, "\\");
+    }
+
+    #[test]
+    fn style_from_css() {
+        assert_eq!(FontStyle::from_css(""), FontStyle::default());
+        assert_eq!(
+            FontStyle::from_css(r#"p{color:ff0000}"#),
+            FontStyle::default().with_fg(Color::RED)
+        );
+        assert_eq!(
+            FontStyle::from_css("p {font-weight: bold; text-decoration: underline}"),
+            FontStyle::default().with_bold(true).with_underline(true)
+        );
+        assert_eq!(
+            FontStyle::from_css("p {font-family: Monospace}"),
+            FontStyle::default().with_font("Monospace")
+        );
+        assert_eq!(
+            FontStyle::from_css("p {font-size: 24}"),
+            FontStyle::default().with_size(18)
+        );
+        assert_eq!(
+            FontStyle::from_css(
+                "<!--color: red; font-weight: bold; font-style: italic; text-decoration: underline; font-family: Serif-->"
+            ),
+            FontStyle::default()
+                .with_fg(Color::RED)
+                .with_bold(true)
+                .with_italic(true)
+                .with_underline(true)
+                .with_font("Serif")
+        );
+    }
+
+    #[test]
+    fn style_to_css() {
+        let base = FontStyle::default();
+        assert_eq!(base.to_css(&base), None);
+        assert_eq!(
+            FontStyle::default().with_size(18).to_css(&base),
+            Some("<!-- p { font-size: 24 } -->".into())
+        );
+        assert_eq!(
+            FontStyle::default()
+                .with_bold(true)
+                .with_underline(true)
+                .to_css(&base),
+            Some("<!-- p { font-weight: bold; text-decoration: underline } -->".into())
+        );
+        assert_eq!(
+            FontStyle::default().with_fg(Color::RED).to_css(&base),
+            Some("<!-- p { color: #ff0000 } -->".into())
+        );
+        assert_eq!(
+            FontStyle::default().with_font("Monospace").to_css(&base),
+            Some("<!-- p { font-family: Monospace } -->".into())
+        );
+        assert_eq!(
+            FontStyle::default()
+                .with_font("Times New Roman")
+                .to_css(&base),
+            Some(r#"<!-- p { font-family: "Times New Roman" } -->"#.into())
+        );
+    }
+}
diff --git a/rust/pspp/src/spv/read/html.rs b/rust/pspp/src/spv/read/html.rs
new file mode 100644 (file)
index 0000000..c4c0e66
--- /dev/null
@@ -0,0 +1,925 @@
+#![warn(dead_code)]
+use std::{
+    borrow::{Borrow, Cow},
+    fmt::{Display, Write as _},
+    io::{Cursor, Write},
+    mem::{discriminant, take},
+    str::FromStr,
+};
+
+use hashbrown::HashMap;
+use html_parser::{Dom, Element, Node};
+use pango::{AttrColor, AttrInt, AttrList, AttrSize, AttrString, IsAttribute};
+use quick_xml::{
+    Writer as XmlWriter,
+    escape::unescape,
+    events::{BytesText, Event},
+};
+use serde::{Deserialize, Deserializer, Serialize, ser::SerializeMap};
+
+use crate::output::pivot::{CellStyle, Color, FontStyle, HorzAlign, Value};
+
+fn lowercase<'a>(s: &'a str) -> Cow<'a, str> {
+    if s.chars().any(|c| c.is_ascii_uppercase()) {
+        Cow::from(s.to_ascii_lowercase())
+    } else {
+        Cow::from(s)
+    }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Markup {
+    Seq(Vec<Markup>),
+    Text(String),
+    Variable(Variable),
+    Style { style: Style, child: Box<Markup> },
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize)]
+pub enum Variable {
+    Date,
+    Time,
+    Head(u8),
+    PageTitle,
+    Page,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)]
+#[error("Unknown variable")]
+pub struct UnknownVariable;
+
+impl FromStr for Variable {
+    type Err = UnknownVariable;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s {
+            "Date" => Ok(Self::Date),
+            "Time" => Ok(Self::Time),
+            "PageTitle" => Ok(Self::PageTitle),
+            "Page" => Ok(Self::Page),
+            _ => {
+                if let Some(suffix) = s.strip_prefix("Head")
+                    && let Ok(number) = suffix.parse()
+                    && number >= 1
+                {
+                    Ok(Self::Head(number))
+                } else {
+                    Err(UnknownVariable)
+                }
+            }
+        }
+    }
+}
+
+impl Variable {
+    fn as_str(&self) -> Cow<'static, str> {
+        match self {
+            Variable::Date => Cow::from("Date"),
+            Variable::Time => Cow::from("Time"),
+            Variable::Head(index) => Cow::from(format!("Head{index}")),
+            Variable::PageTitle => Cow::from("PageTitle"),
+            Variable::Page => Cow::from("Page"),
+        }
+    }
+}
+
+impl Display for Variable {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.as_str())
+    }
+}
+
+impl Default for Markup {
+    fn default() -> Self {
+        Self::Seq(Vec::new())
+    }
+}
+
+impl Serialize for Markup {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        match self {
+            Markup::Seq(inner) => serializer.collect_seq(inner),
+            Markup::Text(string) => serializer.serialize_str(string.as_str()),
+            Markup::Variable(name) => serializer.serialize_newtype_struct("Variable", name),
+            Markup::Style { style, child } => {
+                let (mut style, mut child) = (style, child);
+                let mut styles = HashMap::new();
+                loop {
+                    styles.insert(discriminant(style), style);
+                    match &**child {
+                        Markup::Style {
+                            style: inner,
+                            child: inner_child,
+                        } => {
+                            style = inner;
+                            child = inner_child;
+                        }
+                        _ => break,
+                    }
+                }
+                let mut map = serializer.serialize_map(Some(styles.len() + 1))?;
+                for style in styles.into_values() {
+                    match style {
+                        Style::Bold => map.serialize_entry("bool", &true),
+                        Style::Italic => map.serialize_entry("italic", &true),
+                        Style::Underline => map.serialize_entry("underline", &true),
+                        Style::Strike => map.serialize_entry("strike", &true),
+                        Style::Emphasis => map.serialize_entry("em", &true),
+                        Style::Strong => map.serialize_entry("strong", &true),
+                        Style::Face(name) => map.serialize_entry("font", name),
+                        Style::Color(color) => map.serialize_entry("color", color),
+                        Style::Size(size) => map.serialize_entry("size", size),
+                    }?;
+                }
+                map.serialize_entry("content", child)?;
+                map.end()
+            }
+        }
+    }
+}
+
+impl Display for Markup {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        fn inner(this: &Markup, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+            match this {
+                Markup::Seq(seq) => {
+                    for markup in seq {
+                        inner(markup, f)?;
+                    }
+                    Ok(())
+                }
+                Markup::Text(string) => f.write_str(string.as_str()),
+                Markup::Variable(name) => write!(f, "&[{name}]"),
+                Markup::Style { child, .. } => inner(child, f),
+            }
+        }
+        inner(self, f)
+    }
+}
+
+impl Markup {
+    fn is_empty(&self) -> bool {
+        match self {
+            Markup::Seq(seq) => seq.is_empty(),
+            _ => false,
+        }
+    }
+    fn is_style(&self) -> bool {
+        matches!(self, Markup::Style { .. })
+    }
+    fn into_style(self) -> Option<(Style, Markup)> {
+        match self {
+            Markup::Style { style, child } => Some((style, *child)),
+            _ => None,
+        }
+    }
+    fn is_text(&self) -> bool {
+        matches!(self, Markup::Text(_))
+    }
+    fn as_text(&self) -> Option<&str> {
+        match self {
+            Markup::Text(text) => Some(text.as_str()),
+            _ => None,
+        }
+    }
+    fn into_text(self) -> Option<String> {
+        match self {
+            Markup::Text(text) => Some(text),
+            _ => None,
+        }
+    }
+    fn write_html<X>(&self, writer: &mut XmlWriter<X>) -> std::io::Result<()>
+    where
+        X: Write,
+    {
+        match self {
+            Markup::Seq(children) => {
+                for child in children {
+                    child.write_html(writer)?;
+                }
+            }
+            Markup::Text(text) => writer.write_event(Event::Text(BytesText::new(text.as_str())))?,
+            Markup::Variable(name) => {
+                writer.write_event(Event::Text(BytesText::new(&format!("&[{name}]"))))?
+            }
+            Markup::Style { style, child } => {
+                match style {
+                    Style::Bold => writer.create_element("b"),
+                    Style::Italic => writer.create_element("i"),
+                    Style::Underline => writer.create_element("u"),
+                    Style::Strike => writer.create_element("strike"),
+                    Style::Emphasis => writer.create_element("em"),
+                    Style::Strong => writer.create_element("strong"),
+                    Style::Face(face) => writer
+                        .create_element("font")
+                        .with_attribute(("face", face.as_str())),
+                    Style::Color(color) => writer
+                        .create_element("font")
+                        .with_attribute(("color", color.display_css().to_string().as_str())),
+                    Style::Size(points) => writer
+                        .create_element("font")
+                        .with_attribute(("size", format!("{points}pt").as_str())),
+                }
+                .write_inner_content(|w| child.write_html(w))?;
+            }
+        }
+        Ok(())
+    }
+
+    pub fn to_html(&self) -> String {
+        let mut writer = XmlWriter::new(Cursor::new(Vec::new()));
+        writer
+            .create_element("html")
+            .write_inner_content(|w| self.write_html(w))
+            .unwrap();
+        String::from_utf8(writer.into_inner().into_inner()).unwrap()
+    }
+
+    pub fn to_pango<'a, F>(&self, substitutions: F) -> (String, AttrList)
+    where
+        F: Fn(Variable) -> Option<Cow<'a, str>>,
+    {
+        let mut s = String::new();
+        let mut attrs = AttrList::new();
+        self.to_pango_inner(&substitutions, &mut s, &mut attrs);
+        (s, attrs)
+    }
+
+    fn to_pango_inner<'a, F>(&self, substitutions: &F, s: &mut String, attrs: &mut AttrList)
+    where
+        F: Fn(Variable) -> Option<Cow<'a, str>>,
+    {
+        match self {
+            Markup::Seq(seq) => {
+                for child in seq {
+                    child.to_pango_inner(substitutions, s, attrs);
+                }
+            }
+            Markup::Text(string) => s.push_str(&string),
+            Markup::Variable(variable) => match substitutions(*variable) {
+                Some(value) => s.push_str(&*value),
+                None => write!(s, "&[{variable}]").unwrap(),
+            },
+            Markup::Style { style, child } => {
+                let start_index = s.len();
+                child.to_pango_inner(substitutions, s, attrs);
+                let end_index = s.len();
+
+                let mut attr = match style {
+                    Style::Bold | Style::Strong => {
+                        AttrInt::new_weight(pango::Weight::Bold).upcast()
+                    }
+                    Style::Italic | Style::Emphasis => {
+                        AttrInt::new_style(pango::Style::Italic).upcast()
+                    }
+                    Style::Underline => AttrInt::new_underline(pango::Underline::Single).upcast(),
+                    Style::Strike => AttrInt::new_strikethrough(true).upcast(),
+                    Style::Face(face) => AttrString::new_family(&face).upcast(),
+                    Style::Color(color) => {
+                        let (r, g, b) = color.into_rgb16();
+                        AttrColor::new_foreground(r, g, b).upcast()
+                    }
+                    Style::Size(points) => AttrSize::new((points * 1024.0) as i32).upcast(),
+                };
+                attr.set_start_index(start_index as u32);
+                attr.set_end_index(end_index as u32);
+                attrs.insert(attr);
+            }
+        }
+    }
+
+    fn parse_variables(&self) -> Option<Vec<Markup>> {
+        let Some(mut s) = self.as_text() else {
+            return None;
+        };
+        let mut results = Vec::new();
+        let mut offset = 0;
+        while let Some(start) = s[offset..].find("&[").map(|pos| pos + offset)
+            && let Some(end) = s[start..].find("]").map(|pos| pos + start)
+        {
+            if let Ok(variable) = Variable::from_str(&s[start + 2..end]) {
+                if start > 0 {
+                    results.push(Markup::Text(s[..start].into()));
+                }
+                results.push(Markup::Variable(variable));
+                s = &s[end + 1..];
+                offset = 0;
+            } else {
+                offset = end + 1;
+            }
+        }
+        if results.is_empty() {
+            None
+        } else {
+            if !s.is_empty() {
+                results.push(Markup::Text(s.into()));
+            }
+            Some(results)
+        }
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize)]
+pub struct Paragraph {
+    pub markup: Markup,
+    pub horz_align: HorzAlign,
+}
+
+impl Default for Paragraph {
+    fn default() -> Self {
+        Self {
+            markup: Markup::default(),
+            horz_align: HorzAlign::Left,
+        }
+    }
+}
+
+impl Paragraph {
+    fn new(mut markup: Markup, horz_align: HorzAlign, css: &[Style]) -> Self {
+        for style in css {
+            apply_style(&mut markup, style.clone());
+        }
+        Self { markup, horz_align }
+    }
+
+    fn into_value(self) -> Value {
+        let mut font_style = FontStyle::default().with_size(10);
+        let cell_style = CellStyle::default().with_horz_align(Some(self.horz_align));
+        let mut markup = self.markup;
+        let mut strike = false;
+        while markup.is_style() {
+            let (style, child) = markup.into_style().unwrap();
+            match style {
+                Style::Bold => font_style.bold = true,
+                Style::Italic => font_style.italic = true,
+                Style::Underline => font_style.underline = true,
+                Style::Strike => strike = true,
+                Style::Emphasis => font_style.italic = true,
+                Style::Strong => font_style.bold = true,
+                Style::Face(face) => font_style.font = face,
+                Style::Color(color) => font_style.fg = color,
+                Style::Size(points) => font_style.size = points as i32,
+            };
+            markup = child;
+        }
+        if strike {
+            apply_style(&mut markup, Style::Strike);
+        }
+        if markup.is_text() {
+            Value::new_user_text(markup.into_text().unwrap())
+        } else {
+            Value::new_markup(markup)
+        }
+        .with_font_style(font_style)
+        .with_cell_style(cell_style)
+    }
+}
+
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct Document(pub Vec<Paragraph>);
+
+impl<'de> Deserialize<'de> for Document {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        Ok(Document::from_html(&String::deserialize(deserializer)?))
+    }
+}
+
+impl Serialize for Document {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        self.to_html().serialize(serializer)
+    }
+}
+
+impl Document {
+    pub fn is_empty(&self) -> bool {
+        self.0.is_empty()
+    }
+
+    pub fn from_html(input: &str) -> Self {
+        match Dom::parse(&format!("<!doctype html>{input}")) {
+            Ok(dom) => Self(parse_dom(&dom)),
+            Err(_) if !input.is_empty() => Self(vec![Paragraph {
+                markup: Markup::Text(input.into()),
+                horz_align: HorzAlign::Left,
+            }]),
+            Err(_) => Self::default(),
+        }
+    }
+
+    pub fn into_value(self) -> Value {
+        self.0.into_iter().next().unwrap_or_default().into_value()
+    }
+
+    pub fn to_html(&self) -> String {
+        let mut writer = XmlWriter::new(Cursor::new(Vec::new()));
+        writer
+            .create_element("html")
+            .write_inner_content(|w| {
+                for paragraph in &self.0 {
+                    w.create_element("p")
+                        .with_attribute(("align", paragraph.horz_align.as_str().unwrap()))
+                        .write_inner_content(|w| paragraph.markup.write_html(w))?;
+                }
+                Ok(())
+            })
+            .unwrap();
+
+        // Return the result with `<html>` and `</html>` stripped off.
+        str::from_utf8(&writer.into_inner().into_inner())
+            .unwrap()
+            .strip_prefix("<html>")
+            .unwrap()
+            .strip_suffix("</html>")
+            .unwrap()
+            .into()
+    }
+
+    pub fn to_values(&self) -> Vec<Value> {
+        self.0
+            .iter()
+            .map(|paragraph| paragraph.clone().into_value())
+            .collect()
+    }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Style {
+    Bold,
+    Italic,
+    Underline,
+    Strike,
+    Emphasis,
+    Strong,
+    Face(String),
+    Color(Color),
+    Size(f64),
+}
+
+fn node_as_element<'a>(node: &'a Node, name: &str) -> Option<&'a Element> {
+    if let Node::Element(element) = node
+        && element.name.eq_ignore_ascii_case(name)
+    {
+        Some(element)
+    } else {
+        None
+    }
+}
+
+fn node_is_element(node: &Node, name: &str) -> bool {
+    node_as_element(node, name).is_some()
+}
+
+/// Returns the horizontal alignment for the `<p>` element in `p`.
+fn horz_align_from_p(p: &Element) -> Option<HorzAlign> {
+    if let Some(Some(s)) = p.attributes.get("align")
+        && let Ok(align) = HorzAlign::from_str(s)
+    {
+        Some(align)
+    } else if let Some(Some(s)) = p.attributes.get("style")
+        && let Some(align) = HorzAlign::from_css(s)
+    {
+        Some(align)
+    } else {
+        None
+    }
+}
+
+fn apply_style(markup: &mut Markup, style: Style) {
+    let child = take(markup);
+    *markup = Markup::Style {
+        style,
+        child: Box::new(child),
+    };
+}
+
+pub fn parse_dom(dom: &Dom) -> Vec<Paragraph> {
+    // Get the top-level elements, descending into an `html` element if
+    // there is one.
+    let roots = if dom.children.len() == 1
+        && let Some(first) = dom.children.first()
+        && let Some(html) = node_as_element(first, "html")
+    {
+        &html.children
+    } else {
+        &dom.children
+    };
+
+    // If there's a `head` element, parse it for CSS and then skip past it.
+    let mut css = Vec::new();
+    let mut default_horz_align = HorzAlign::Left;
+    let roots = if let Some((first, rest)) = roots.split_first()
+        && let Some(head) = node_as_element(first, "head")
+    {
+        if let Some(style) = find_element(&head.children, "style") {
+            let mut text = String::new();
+            get_element_text(style, &mut text);
+            Style::parse_css(&mut css, &text);
+            if let Some(horz_align) = HorzAlign::from_css(&text) {
+                default_horz_align = horz_align;
+            }
+        }
+        rest
+    } else {
+        roots
+    };
+
+    // If only a `body` element is left, descend into it.
+    let body = if roots.len() == 1
+        && let Some(first) = roots.first()
+        && let Some(body) = node_as_element(first, "body")
+    {
+        &body.children
+    } else {
+        roots
+    };
+
+    let mut paragraphs = Vec::new();
+
+    let mut start = 0;
+    while start < body.len() {
+        let (end, align) = if let Some(p) = node_as_element(&body[start], "p") {
+            (
+                start + 1,
+                horz_align_from_p(p).unwrap_or(default_horz_align),
+            )
+        } else {
+            let mut end = start + 1;
+            while end < body.len() && !node_is_element(&body[end], "p") {
+                end += 1;
+            }
+            (end, default_horz_align)
+        };
+        paragraphs.push(Paragraph::new(parse_nodes(&body[start..end]), align, &css));
+        start = end;
+    }
+
+    paragraphs
+}
+
+fn parse_nodes(nodes: &[Node]) -> Markup {
+    // Appends `markup` to `dst`, merging text at the end of `dst` with text
+    // in `markup`.
+    fn add_markup(dst: &mut Vec<Markup>, markup: Markup) {
+        if let Markup::Text(suffix) = &markup
+            && let Some(Markup::Text(last)) = dst.last_mut()
+        {
+            last.push_str(&suffix);
+        } else {
+            dst.push(markup);
+        }
+
+        if let Some(mut expansion) = dst.last().unwrap().parse_variables() {
+            dst.pop();
+            dst.append(&mut expansion);
+        }
+    }
+
+    let mut retval = Vec::new();
+    for (i, node) in nodes.iter().enumerate() {
+        match node {
+            Node::Comment(_) => (),
+            Node::Text(text) => {
+                let text = if i == 0 {
+                    text.trim_start()
+                } else {
+                    text.as_str()
+                };
+                let text = if i == nodes.len() - 1 {
+                    text.trim_end()
+                } else {
+                    text
+                };
+                add_markup(
+                    &mut retval,
+                    Markup::Text(unescape(&text).unwrap_or(Cow::from(text)).into_owned()),
+                );
+            }
+            Node::Element(br) if br.name.eq_ignore_ascii_case("br") => {
+                add_markup(&mut retval, Markup::Text('\n'.into()));
+            }
+            Node::Element(element) => {
+                let mut inner = parse_nodes(&element.children);
+                if inner.is_empty() {
+                    continue;
+                }
+
+                let style = match lowercase(&element.name).borrow() {
+                    "b" => Some(Style::Bold),
+                    "i" => Some(Style::Italic),
+                    "u" => Some(Style::Underline),
+                    "s" | "strike" => Some(Style::Strike),
+                    "strong" => Some(Style::Strong),
+                    "em" => Some(Style::Emphasis),
+                    "font" => {
+                        if let Some(Some(face)) = element.attributes.get("face") {
+                            apply_style(&mut inner, Style::Face(face.clone()));
+                        }
+                        if let Some(Some(color)) = element.attributes.get("color")
+                            && let Ok(color) = Color::from_str(&color)
+                        {
+                            apply_style(&mut inner, Style::Color(color));
+                        }
+                        if let Some(Some(html_size)) = element.attributes.get("size")
+                            && let Ok(html_size) = usize::from_str(&html_size)
+                            && let Some(index) = html_size.checked_sub(1)
+                            && let Some(points) =
+                                [6.0, 7.5, 9.0, 10.5, 13.5, 18.0, 27.0].get(index).copied()
+                        {
+                            apply_style(&mut inner, Style::Size(points));
+                        }
+                        None
+                    }
+                    _ => None,
+                };
+                match style {
+                    None => match inner {
+                        Markup::Seq(seq) => {
+                            for markup in seq {
+                                add_markup(&mut retval, markup);
+                            }
+                        }
+                        _ => add_markup(&mut retval, inner),
+                    },
+                    Some(style) => retval.push(Markup::Style {
+                        style,
+                        child: Box::new(inner),
+                    }),
+                }
+            }
+        }
+    }
+    if retval.len() == 1 {
+        retval.into_iter().next().unwrap()
+    } else {
+        Markup::Seq(retval)
+    }
+}
+
+fn find_element<'a>(elements: &'a [Node], name: &str) -> Option<&'a Element> {
+    for element in elements {
+        if let Node::Element(element) = element
+            && element.name == name
+        {
+            return Some(element);
+        }
+    }
+    None
+}
+
+fn parse_entity(s: &str) -> (char, &str) {
+    static ENTITIES: [(&str, char); 6] = [
+        ("amp;", '&'),
+        ("lt;", '<'),
+        ("gt;", '>'),
+        ("apos;", '\''),
+        ("quot;", '"'),
+        ("nbsp;", '\u{00a0}'),
+    ];
+    for (name, ch) in ENTITIES {
+        if let Some(rest) = s.strip_prefix(name) {
+            return (ch, rest);
+        }
+    }
+    ('&', s)
+}
+
+fn get_node_text(node: &Node, text: &mut String) {
+    match node {
+        Node::Text(string) => {
+            let mut s = string.as_str();
+            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;
+                }
+                let ch;
+                (ch, s) = parse_entity(&s[1..]);
+                text.push(ch);
+            }
+        }
+        Node::Element(element) => get_element_text(element, text),
+        Node::Comment(_) => (),
+    }
+}
+
+fn get_element_text(element: &Element, text: &mut String) {
+    for child in &element.children {
+        get_node_text(child, text);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::{borrow::Cow, str::FromStr};
+
+    use crate::spv::read::html::{self, Document, Markup, Variable};
+
+    #[test]
+    fn variable() {
+        assert_eq!(Variable::from_str("Head1").unwrap(), Variable::Head(1));
+        assert_eq!(Variable::from_str("Page").unwrap(), Variable::Page);
+        assert_eq!(Variable::from_str("Date").unwrap(), Variable::Date);
+        assert_eq!(Variable::Head(1).to_string(), "Head1");
+        assert_eq!(Variable::Page.to_string(), "Page");
+        assert_eq!(Variable::Date.to_string(), "Date");
+    }
+
+    #[test]
+    fn parse_variables() {
+        assert_eq!(Markup::Text("asdf".into()).parse_variables(), None);
+        assert_eq!(Markup::Text("&[asdf]".into()).parse_variables(), None);
+        assert_eq!(
+            Markup::Text("&[Page]".into()).parse_variables(),
+            Some(vec![Markup::Variable(Variable::Page)])
+        );
+        assert_eq!(
+            Markup::Text("xyzzy &[Invalid] &[Page] &[Invalid2] quux".into()).parse_variables(),
+            Some(vec![
+                Markup::Text("xyzzy &[Invalid] ".into()),
+                Markup::Variable(Variable::Page),
+                Markup::Text(" &[Invalid2] quux".into()),
+            ])
+        );
+    }
+
+    /// Example from the documentation.
+    #[test]
+    fn example1() {
+        let text = r##"<xml>&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+  &lt;head>
+
+  &lt;/head>
+  &lt;body>
+    &lt;p>
+      plain&amp;#160;&lt;font color="#000000" size="3" face="Monospaced">&lt;b>bold&lt;/b>&lt;/font>&amp;#160;&lt;font color="#000000" size="3" face="Monospaced">&lt;i>italic&lt;/i>&amp;#160;&lt;strike>strikeout&lt;/strike>&lt;/font>
+    &lt;/p>
+  &lt;/body>
+&lt;/html>
+</xml>"##;
+        let content = quick_xml::de::from_str::<String>(text).unwrap();
+        assert_eq!(
+            Document::from_html(&content).to_html(),
+            r##"<p align="left">plain <font size="9pt"><font color="#000000"><font face="Monospaced"><b>bold</b></font></font></font> <font size="9pt"><font color="#000000"><font face="Monospaced"><i>italic</i> <strike>strikeout</strike></font></font></font></p>"##
+        );
+    }
+
+    /// Another example from the documentation.
+    #[test]
+    fn example2() {
+        let text = r##"<xml>&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+  &lt;head>
+
+  &lt;/head>
+  &lt;body>
+    &lt;p>left&lt;/p>
+    &lt;p align="center">&lt;font color="#000000" size="5" face="Monospaced">center&amp;#160;large&lt;/font>&lt;/p>
+    &lt;p align="right">&lt;font color="#000000" size="3" face="Monospaced">&lt;b>&lt;i>right&lt;/i>&lt;/b>&lt;/font>&lt;/p>
+  &lt;/body>
+&lt;/html></xml>
+"##;
+        let content = quick_xml::de::from_str::<String>(text).unwrap();
+        assert_eq!(
+            Document::from_html(&content).to_html(),
+            r##"<p align="left">left</p><p align="center"><font size="13.5pt"><font color="#000000"><font face="Monospaced">center large</font></font></font></p><p align="right"><font size="9pt"><font color="#000000"><font face="Monospaced"><b><i>right</i></b></font></font></font></p>"##
+        );
+    }
+
+    /*
+    #[test]
+    fn value() {
+        let value = parse_value(
+            r#"<b>bold</b><br><i>italic</i><BR><b><i>bold italic</i></b><br><font color="red" face="Serif">red serif</font><br><font size="7">big</font><br>"#,
+        );
+        assert_eq!(
+            value,
+            Value::new_markup(
+                r##"<b>bold</b>
+<i>italic</i>
+<b><i>bold italic</i></b>
+<span face="Serif" color="#ff0000">red serif</span>
+<span size="20480">big</span>
+"##
+            )
+            .with_font_style(FontStyle::default().with_size(10))
+        );
+    }*/
+
+    /// From the corpus (also included in the documentation).
+    #[test]
+    fn header1() {
+        let text = r##"<xml>&lt;html xmlns="http://xml.spss.com/spss/viewer/viewer-tree">
+  &lt;head>
+
+  &lt;/head>
+  &lt;body>
+    &lt;p style="text-align:center; margin-top: 0">
+      &amp;[PageTitle]
+    &lt;/p>
+  &lt;/body>
+&lt;/html></xml>"##;
+        let content = quick_xml::de::from_str::<String>(text).unwrap();
+        assert_eq!(
+            Document::from_html(&content).to_html(),
+            r##"<p align="center">&amp;[PageTitle]</p>"##
+        );
+    }
+
+    /// From the corpus (also included in the documentation).
+    #[test]
+    fn footer1() {
+        let text = r##"<xml>&lt;html xmlns="http://xml.spss.com/spss/viewer/viewer-tree">
+  &lt;head>
+
+  &lt;/head>
+  &lt;body>
+    &lt;p style="text-align:right; margin-top: 0">
+      Page &amp;[Page]
+    &lt;/p>
+  &lt;/body>
+&lt;/html></xml>"##;
+        let content = quick_xml::de::from_str::<String>(text).unwrap();
+        assert_eq!(
+            Document::from_html(&content).to_html(),
+            r##"<p align="right">Page &amp;[Page]</p>"##
+        );
+    }
+
+    /// From the corpus (also included in the documentation).
+    #[test]
+    fn header2() {
+        let text = r##"<xml>&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+  &lt;head>
+          &lt;style type="text/css">
+                  p { font-family: sans-serif;
+                       font-size: 10pt; text-align: center;
+                       font-weight: normal;
+                       color: #000000;
+                       }
+          &lt;/style>
+  &lt;/head>
+  &lt;body>
+          &lt;p>&amp;amp;[PageTitle]&lt;/p>
+  &lt;/body>
+&lt;/html></xml>"##;
+        let content = quick_xml::de::from_str::<String>(text).unwrap();
+        let document = Document::from_html(&content);
+        assert_eq!(
+            document.to_html(),
+            r##"<p align="center"><font color="#000000"><font face="sans-serif">&amp;[PageTitle]</font></font></p>"##
+        );
+        assert_eq!(
+            document.0[0]
+                .markup
+                .to_pango(
+                    |name| (name == html::Variable::PageTitle).then_some(Cow::from("The title"))
+                )
+                .0,
+            "The title"
+        );
+    }
+
+    /// From the corpus (also included in the documentation).
+    #[test]
+    fn footer2() {
+        let text = r##"<xml>&lt;html xmlns="http://www.w3.org/1999/xhtml" lang="en">
+  &lt;head>
+          &lt;style type="text/css">
+                  p { font-family: sans-serif;
+                       font-size: 10pt; text-align: right;
+                       font-weight: normal;
+                       color: #000000;
+                       }
+          &lt;/style>
+  &lt;/head>
+  &lt;body>
+          &lt;p>Page &amp;amp;[Page]&lt;/p>
+  &lt;/body>
+&lt;/html>
+</xml>"##;
+        let content = quick_xml::de::from_str::<String>(text).unwrap();
+        let html = Document::from_html(&content);
+        assert_eq!(
+            html.to_html(),
+            r##"<p align="right"><font color="#000000"><font face="sans-serif">Page &amp;[Page]</font></font></p>"##
+        );
+    }
+
+    /// Checks that the `escape-html` feature is enabled in [quick_xml], since
+    /// we need that to resolve `&nbsp;` and other HTML entities.
+    #[test]
+    fn html_escapes() {
+        let html = Document::from_html("&nbsp;");
+        assert_eq!(html.to_html(), "<p align=\"left\">\u{a0}</p>")
+    }
+}
diff --git a/rust/pspp/src/spv/read/legacy_bin.rs b/rust/pspp/src/spv/read/legacy_bin.rs
new file mode 100644 (file)
index 0000000..07fca9e
--- /dev/null
@@ -0,0 +1,274 @@
+use std::{
+    collections::HashMap,
+    io::{Read, Seek, SeekFrom},
+};
+
+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,
+    format::{Category, Format},
+    output::pivot::Value,
+    spv::read::light::{U32String, decode_format, parse_vec},
+};
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+pub struct LegacyBin {
+    #[br(magic(0u8), temp)]
+    version: Version,
+    #[br(temp)]
+    n_sources: u16,
+    #[br(temp)]
+    _member_size: u32,
+    #[br(count(n_sources), args { inner: (version,) })]
+    metadata: Vec<Metadata>,
+    #[br(parse_with(parse_data), args(metadata.as_slice()))]
+    data: Vec<Data>,
+    #[br(parse_with(parse_strings))]
+    strings: Option<Strings>,
+}
+
+impl LegacyBin {
+    pub fn decode(&self) -> HashMap<String, HashMap<String, Vec<DataValue>>> {
+        let mut sources = HashMap::new();
+        for (metadata, data) in self.metadata.iter().zip(&self.data) {
+            let mut variables = HashMap::new();
+            for variable in &data.variables {
+                variables.insert(
+                    variable.variable_name.clone(),
+                    variable
+                        .values
+                        .iter()
+                        .map(|value| DataValue {
+                            index: None,
+                            value: Datum::Number((*value != f64::MIN).then_some(*value)),
+                        })
+                        .collect::<Vec<_>>(),
+                );
+            }
+            sources.insert(metadata.source_name.clone(), variables);
+        }
+        if let Some(strings) = &self.strings {
+            for map in &strings.source_maps {
+                let source = sources.get_mut(&map.source_name).unwrap(); // XXX unwrap
+                for var_map in &map.variable_maps {
+                    let variable = source.get_mut(&var_map.variable_name).unwrap(); // XXX unwrap
+                    for datum_map in &var_map.datum_maps {
+                        // XXX two possibly out-of-range indexes below
+                        variable[datum_map.value_idx].value =
+                            Datum::String(strings.labels[datum_map.label_idx].label.clone());
+                    }
+                }
+            }
+        }
+        sources
+    }
+}
+
+#[derive(Clone, Debug)]
+pub struct DataValue {
+    pub index: Option<f64>,
+    pub value: Datum<String>,
+}
+
+impl DataValue {
+    pub fn category(&self) -> Option<usize> {
+        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))
+    }
+
+    // This should probably be a method on some hypothetical FormatMap.
+    pub fn as_format(&self, format_map: &HashMap<i64, Format>) -> Format {
+        let f = match &self.value {
+            Datum::Number(Some(number)) => *number as i64,
+            Datum::Number(None) => 0,
+            Datum::String(s) => s.parse().unwrap_or_default(),
+        };
+        match format_map.get(&f) {
+            Some(format) => *format,
+            None => decode_format(f as u32),
+        }
+    }
+
+    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)]
+enum Version {
+    #[br(magic = 0xafu8)]
+    Vaf,
+    #[br(magic = 0xb0u8)]
+    Vb0,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct Metadata {
+    n_values: u32,
+    n_variables: u32,
+    data_offset: u32,
+    #[br(parse_with(parse_fixed_utf8_string), args(if version == Version::Vaf { 28 } else { 64 }))]
+    source_name: String,
+    #[br(if(version == Version::Vb0), temp)]
+    _x: u32,
+}
+
+#[derive(Debug)]
+struct Data {
+    variables: Vec<Variable>,
+}
+
+#[binrw::parser(reader, endian)]
+fn parse_data(metadata: &[Metadata]) -> BinResult<Vec<Data>> {
+    let mut data = Vec::with_capacity(metadata.len());
+    for metadata in metadata {
+        reader.seek(SeekFrom::Start(metadata.data_offset as u64))?;
+        let mut variables = Vec::with_capacity(metadata.n_variables as usize);
+        for _ in 0..metadata.n_variables {
+            variables.push(Variable::read_options(
+                reader,
+                endian,
+                (metadata.n_values,),
+            )?);
+        }
+        data.push(Data { variables });
+    }
+    Ok(data)
+}
+
+impl BinRead for Data {
+    type Args<'a> = &'a [Metadata];
+
+    fn read_options<R: Read + Seek>(
+        reader: &mut R,
+        endian: binrw::Endian,
+        metadata: Self::Args<'_>,
+    ) -> binrw::BinResult<Self> {
+        let mut variables = Vec::with_capacity(metadata.len());
+        for metadata in metadata {
+            reader.seek(SeekFrom::Start(metadata.data_offset as u64))?;
+            variables.push(Variable::read_options(
+                reader,
+                endian,
+                (metadata.n_values,),
+            )?);
+        }
+        Ok(Self { variables })
+    }
+}
+
+#[binread]
+#[br(little, import(n_values: u32))]
+#[derive(Debug)]
+struct Variable {
+    #[br(parse_with(parse_fixed_utf8_string), args(288))]
+    variable_name: String,
+    #[br(count(n_values))]
+    values: Vec<f64>,
+}
+
+#[binrw::parser(reader, endian)]
+fn parse_strings() -> BinResult<Option<Strings>> {
+    let position = reader.stream_position()?;
+    let length = reader.seek(SeekFrom::End(0))?;
+    if position != length {
+        reader.seek(SeekFrom::Start(position))?;
+        Ok(Some(Strings::read_options(reader, endian, ())?))
+    } else {
+        Ok(None)
+    }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Strings {
+    #[br(parse_with(parse_vec))]
+    source_maps: Vec<SourceMap>,
+    #[br(parse_with(parse_vec))]
+    labels: Vec<Label>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct SourceMap {
+    #[br(parse_with(parse_utf8_string))]
+    source_name: String,
+    #[br(parse_with(parse_vec))]
+    variable_maps: Vec<VariableMap>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct VariableMap {
+    #[br(parse_with(parse_utf8_string))]
+    variable_name: String,
+    #[br(parse_with(parse_vec))]
+    datum_maps: Vec<DatumMap>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct DatumMap {
+    #[br(map(|x: u32| x as usize))]
+    value_idx: usize,
+    #[br(map(|x: u32| x as usize))]
+    label_idx: usize,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Label {
+    #[br(temp)]
+    _frequency: u32,
+    #[br(parse_with(parse_utf8_string))]
+    label: String,
+}
+
+/// Parses a UTF-8 string preceded by a 32-bit length.
+#[binrw::parser(reader, endian)]
+pub(super) fn parse_utf8_string() -> BinResult<String> {
+    Ok(U32String::read_options(reader, endian, ())?.decode(UTF_8))
+}
+
+/// Parses a UTF-8 string that is exactly `n` bytes long and whose contents end
+/// at the first null byte.
+#[binrw::parser(reader)]
+pub(super) fn parse_fixed_utf8_string(n: usize) -> BinResult<String> {
+    let mut buf = vec![0; n];
+    reader.read_exact(&mut buf)?;
+    let len = buf.iter().take_while(|b| **b != 0).count();
+    Ok(
+        std::str::from_utf8(&buf[..len]).unwrap().into(), // XXX unwrap
+    )
+}
diff --git a/rust/pspp/src/spv/read/legacy_xml.rs b/rust/pspp/src/spv/read/legacy_xml.rs
new file mode 100644 (file)
index 0000000..97892a5
--- /dev/null
@@ -0,0 +1,2678 @@
+// 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/>.
+
+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 ordered_float::OrderedFloat;
+use serde::Deserialize;
+
+use crate::{
+    calendar::{date_time_to_pspp, time_to_pspp},
+    data::Datum,
+    format::{self, Decimal::Dot, F8_0, F40_2, Type, UncheckedFormat},
+    output::pivot::{
+        self, Area, AreaStyle, Axis2, Axis3, Category, CategoryLocator, CellStyle, Color,
+        Dimension, Group, HeadingRegion, HorzAlign, Leaf, Length, Look, NumberValue, PivotTable,
+        RowParity, Value, ValueInner, VertAlign,
+    },
+    spv::read::legacy_bin::DataValue,
+};
+
+#[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 new() -> Self {
+        Self::default()
+    }
+
+    fn remap_formats(
+        &mut self,
+        format: &Option<Format>,
+        string_format: &Option<StringFormat>,
+    ) -> (crate::format::Format, Vec<Affix>) {
+        let (format, affixes, relabels, try_strings_as_numbers) = if let Some(format) = &format {
+            (
+                Some(format.decode()),
+                format.affixes.clone(),
+                format.relabels.as_slice(),
+                format.try_strings_as_numbers.unwrap_or_default(),
+            )
+        } else if let Some(string_format) = &string_format {
+            (
+                None,
+                string_format.affixes.clone(),
+                string_format.relabels.as_slice(),
+                false,
+            )
+        } else {
+            (None, Vec::new(), [].as_slice(), false)
+        };
+        for relabel in relabels {
+            let value = if try_strings_as_numbers && let Ok(to) = relabel.to.trim().parse::<f64>() {
+                Datum::Number(Some(to))
+            } else if let Some(format) = format
+                && let Ok(to) = relabel.to.trim().parse::<f64>()
+            {
+                Datum::String(
+                    Datum::<String>::Number(Some(to))
+                        .display(format)
+                        .with_stretch()
+                        .to_string(),
+                )
+            } else {
+                Datum::String(relabel.to.clone())
+            };
+            self.0.insert(OrderedFloat(relabel.from), value);
+            // XXX warn on duplicate
+        }
+        (format.unwrap_or(F8_0), affixes)
+    }
+
+    fn apply(&self, data: &mut Vec<DataValue>) {
+        for value in data {
+            let Datum::Number(Some(number)) = value.value else {
+                continue;
+            };
+            if let Some(to) = self.0.get(&OrderedFloat(number)) {
+                value.index = Some(number);
+                value.value = to.clone();
+            }
+        }
+    }
+
+    fn insert_labels(
+        &mut self,
+        data: &[DataValue],
+        label_series: &Series,
+        format: crate::format::Format,
+    ) {
+        for (value, label) in data.iter().zip(label_series.values.iter()) {
+            if let Some(Some(number)) = value.value.as_number() {
+                let dest = match &label.value {
+                    Datum::Number(_) => label.value.display(format).with_stretch().to_string(),
+                    Datum::String(s) => s.clone(),
+                };
+                self.0.insert(OrderedFloat(number), Datum::String(dest));
+            }
+        }
+    }
+
+    fn remap_vmes(&mut self, value_map: &[ValueMapEntry]) {
+        for vme in value_map {
+            for from in vme.from.split(';') {
+                let from = from.trim().parse::<f64>().unwrap(); // XXX
+                let to = if let Ok(to) = vme.to.trim().parse::<f64>() {
+                    Datum::Number(Some(to))
+                } else {
+                    Datum::String(vme.to.clone())
+                };
+                self.0.insert(OrderedFloat(from), to);
+            }
+        }
+    }
+
+    fn lookup<'a>(&'a self, dv: &'a DataValue) -> &'a Datum<String> {
+        if let Datum::Number(Some(number)) = &dv.value
+            && let Some(value) = self.0.get(&OrderedFloat(*number))
+        {
+            value
+        } else {
+            &dv.value
+        }
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct Visualization {
+    /// In format `YYYY-MM-DD`.
+    #[serde(rename = "@date")]
+    date: String,
+    // Locale used for output, e.g. `en-US`.
+    #[serde(rename = "@lang")]
+    lang: String,
+    /// Localized title of the pivot table.
+    #[serde(rename = "@name")]
+    name: String,
+    /// Base style for the pivot table.
+    #[serde(rename = "@style")]
+    style: Ref<Style>,
+
+    #[serde(rename = "$value")]
+    children: Vec<VisChild>,
+}
+
+impl Visualization {
+    pub fn decode(
+        &self,
+        data: HashMap<String, HashMap<String, Vec<DataValue>>>,
+        mut look: Look,
+    ) -> Result<PivotTable, super::Error> {
+        let mut extension = None;
+        let mut user_source = None;
+        let mut source_variables = Vec::new();
+        let mut derived_variables = Vec::new();
+        let mut graph = None;
+        let mut labels = EnumMap::from_fn(|_| Vec::new());
+        let mut styles = HashMap::new();
+        let mut _layer_controller = None;
+        for child in &self.children {
+            match child {
+                VisChild::Extension(e) => extension = Some(e),
+                VisChild::UserSource(us) => user_source = Some(us),
+                VisChild::SourceVariable(source_variable) => source_variables.push(source_variable),
+                VisChild::DerivedVariable(derived_variable) => {
+                    derived_variables.push(derived_variable)
+                }
+                VisChild::CategoricalDomain(_) => (),
+                VisChild::Graph(g) => graph = Some(g),
+                VisChild::LabelFrame(label_frame) => {
+                    if let Some(label) = &label_frame.label
+                        && let Some(purpose) = label.purpose
+                    {
+                        labels[purpose].push(label);
+                    }
+                }
+                VisChild::Container(c) => {
+                    for label_frame in &c.label_frames {
+                        if let Some(label) = &label_frame.label
+                            && let Some(purpose) = label.purpose
+                        {
+                            labels[purpose].push(label);
+                        }
+                    }
+                }
+                VisChild::Style(style) => {
+                    if let Some(id) = &style.id {
+                        styles.insert(id.as_str(), style);
+                    }
+                }
+                VisChild::LayerController(lc) => _layer_controller = Some(lc),
+            }
+        }
+        let Some(graph) = graph else { todo!() };
+        let Some(_user_source) = user_source else {
+            todo!()
+        };
+
+        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,
+                );
+            }
+        }
+
+        // Footnotes.
+        //
+        // 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 footnote_builder = BTreeMap::<usize, Footnote>::new();
+        if let Some(f) = &graph.interval.footnotes {
+            f.decode(&mut footnote_builder);
+        }
+        for child in &graph.interval.labeling.children {
+            if let LabelingChild::Footnotes(f) = child {
+                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 = footnote_builder
+                        .entry(uses_reference.get() - 1)
+                        .or_default();
+                    if index % 2 == 0 {
+                        entry.content = text.text.strip_suffix('\n').unwrap_or(&text.text).into();
+                    } else {
+                        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),
+            (Purpose::SubTitle, Area::Caption),
+            (Purpose::Layer, Area::Layers),
+            (Purpose::Footnote, Area::Footer),
+        ] {
+            for label in &labels[purpose] {
+                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_area(
+                Some(*style),
+                graph.cell_style.get(&styles),
+                &mut look.areas[Area::Data(RowParity::Even)],
+            );
+            look.areas[Area::Data(RowParity::Odd)] =
+                look.areas[Area::Data(RowParity::Even)].clone();
+        }
+
+        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())
+            && let Some(width) = &style.width
+        {
+            let mut parts = width.split(';');
+            parts.next();
+            if let Some(min_width) = parts.next()
+                && let Some(max_width) = parts.next()
+                && let Ok(min_width) = min_width.parse::<Length>()
+                && let Ok(max_width) = max_width.parse::<Length>()
+            {
+                look.heading_widths[HeadingRegion::Columns] =
+                    min_width.as_pt_f64() as isize..=max_width.as_pt_f64() as isize;
+            }
+        }
+
+        let mut series = HashMap::<&str, Series>::new();
+        while let n_source = source_variables.len()
+            && let n_derived = derived_variables.len()
+            && (n_source > 0 || n_derived > 0)
+        {
+            for sv in take(&mut source_variables) {
+                match sv.decode(&data, &series) {
+                    Ok(s) => {
+                        series.insert(&sv.id, s);
+                    }
+                    Err(()) => source_variables.push(sv),
+                }
+            }
+
+            for dv in take(&mut derived_variables) {
+                match dv.decode(&series) {
+                    Ok(s) => {
+                        series.insert(&dv.id, s);
+                    }
+                    Err(()) => derived_variables.push(dv),
+                }
+            }
+
+            if n_source == source_variables.len() && n_derived == derived_variables.len() {
+                unreachable!();
+            }
+        }
+
+        fn decode_dimension<'a>(
+            variables: &[(&'a Series, usize)],
+            axes: &HashMap<usize, &Axis>,
+            styles: &HashMap<&str, &Style>,
+            a: Axis3,
+            look: &mut Look,
+            rotate_inner_column_labels: &mut bool,
+            rotate_outer_row_labels: &mut bool,
+            footnotes: &pivot::Footnotes,
+            dims: &mut Vec<Dim<'a>>,
+        ) {
+            let base_level = variables[0].1;
+            let show_label = if let Ok(a) = Axis2::try_from(a)
+                && let Some(axis) = axes.get(&(base_level + variables.len()))
+                && let Some(label) = &axis.label
+            {
+                let out = &mut look.areas[Area::Labels(a)];
+                *out = AreaStyle::default_for_area(Area::Labels(a));
+                let style = label.style.get(&styles);
+                Style::decode_area(
+                    style,
+                    label.text_frame_style.as_ref().and_then(|r| r.get(styles)),
+                    out,
+                );
+                style.is_some_and(|s| s.visible.unwrap_or_default())
+            } else {
+                false
+            };
+            if a == Axis3::Y
+                && let Some(axis) = axes.get(&(base_level + variables.len() - 1))
+            {
+                Style::decode_area(
+                    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;
+                }
+            }
+
+            let variables = variables
+                .into_iter()
+                .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();
+            for (index, value) in variables[0].values.iter().enumerate() {
+                let Some(row) = value.category() else {
+                    continue;
+                };
+                coordinate_to_index.insert(row, CategoryLocator::new_leaf(index));
+                let name = variables[0].new_name(value, footnotes);
+                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].leaves.start];
+                    if end < cats.len()
+                        && variables[j].values[cats[end].leaves.clone()]
+                            .iter()
+                            .all(|dv| &dv.value == &dv1.value)
+                    {
+                    } else {
+                        let name = variables[j].map.lookup(dv1);
+                        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].category.clone());
+                            }
+                            CatBuilder {
+                                category: Category::from(group),
+                                leaves: cats[start].leaves.start..cats[end - 1].leaves.end,
+                                location: cats[start].location.parent(),
+                            }
+                        } else {
+                            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;
+            }
+
+            let dimension = Dimension::new(
+                Group::new(
+                    variables[0]
+                        .label
+                        .as_ref()
+                        .map_or_else(|| Value::empty(), |label| Value::new_user_text(label)),
+                )
+                .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,
+                coordinate: variables[0],
+            });
+        }
+
+        fn decode_dimensions<'a, 'b>(
+            variables: impl IntoIterator<Item = &'a str>,
+            series: &'b HashMap<&str, Series>,
+            axes: &HashMap<usize, &Axis>,
+            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,
+            dims: &mut Vec<Dim<'b>>,
+        ) {
+            let variables = variables
+                .into_iter()
+                .zip(level_ofs..)
+                .map(|(variable_name, level)| {
+                    series
+                        .get(variable_name)
+                        .filter(|s| !s.values.is_empty())
+                        .map(|s| (s, level))
+                })
+                .collect::<Vec<_>>();
+            let mut dim_vars = Vec::new();
+            for var in variables {
+                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,
+                        rotate_inner_column_labels,
+                        rotate_outer_row_labels,
+                        footnotes,
+                        dims,
+                    );
+                    dim_vars.clear();
+                }
+            }
+            if !dim_vars.is_empty() {
+                decode_dimension(
+                    &dim_vars,
+                    axes,
+                    styles,
+                    a,
+                    look,
+                    rotate_inner_column_labels,
+                    rotate_outer_row_labels,
+                    footnotes,
+                    dims,
+                );
+            }
+        }
+
+        struct Dim<'a> {
+            axis: Axis3,
+            dimension: pivot::Dimension,
+            coordinate: &'a Series,
+        }
+
+        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();
+        let mut dims = Vec::new();
+        decode_dimensions(
+            columns.into_iter().map(|vr| vr.reference.as_str()),
+            &series,
+            &axes,
+            &styles,
+            Axis3::X,
+            &mut look,
+            &mut rotate_inner_column_labels,
+            &mut rotate_outer_row_labels,
+            &footnotes,
+            1,
+            &mut dims,
+        );
+        let rows = cross
+            .get(1)
+            .map(|child| child.variables())
+            .unwrap_or_default();
+        decode_dimensions(
+            rows.into_iter().map(|vr| vr.reference.as_str()),
+            &series,
+            &axes,
+            &styles,
+            Axis3::Y,
+            &mut look,
+            &mut rotate_inner_column_labels,
+            &mut rotate_outer_row_labels,
+            &footnotes,
+            1 + columns.len(),
+            &mut dims,
+        );
+
+        let mut level_ofs = columns.len() + rows.len() + 1;
+        for layers in [&graph.faceting.layers1, &graph.faceting.layers2] {
+            decode_dimensions(
+                layers.iter().map(|layer| layer.variable.as_str()),
+                &series,
+                &axes,
+                &styles,
+                Axis3::Y,
+                &mut look,
+                &mut rotate_inner_column_labels,
+                &mut rotate_outer_row_labels,
+                &footnotes,
+                level_ofs,
+                &mut dims,
+            );
+            level_ofs += layers.len();
+        }
+
+        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);
+        let cell_footnotes =
+            graph
+                .interval
+                .labeling
+                .children
+                .iter()
+                .find_map(|child| match child {
+                    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 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);
+            }
+
+            let format = if let Some(cell_formats) = &cell_formats {
+                // XXX indexing of values
+                cell_formats.values[i].as_format(&format_map)
+            } else {
+                F40_2
+            };
+            let mut value = cell.as_pivot_value(format);
+
+            if let Some(cell_footnotes) = &cell_footnotes {
+                // XXX indexing
+                let dv = &cell_footnotes.values[i];
+                if let Some(s) = dv.value.as_string() {
+                    for part in s.split(',') {
+                        if let Ok(index) = part.parse::<usize>()
+                            && let Some(index) = index.checked_sub(1)
+                            && let Some(footnote) = footnotes.get(index)
+                        {
+                            value = value.with_footnote(footnote);
+                        }
+                    }
+                }
+            }
+            if let Value {
+                inner: ValueInner::Number(NumberValue { value: None, .. }),
+                styling: None,
+            } = &value
+            {
+                // A system-missing value without a footnote represents an empty cell.
+            } else {
+                // XXX cell_index might be invalid?
+                data.insert(coords.clone(), value);
+            }
+        }
+
+        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 = AreaStyle::default_for_area(Area::Data(RowParity::Odd));
+                            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;
+                                };
+                                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)
+                && let Ok(index) = usize::try_from(index)
+                && let Some(footnote) = footnotes.get(index)
+            {
+                value = value.with_footnote(footnote);
+            }
+        }
+        value
+    }
+
+    fn new_name(&self, dv: &DataValue, footnotes: &pivot::Footnotes) -> Value {
+        let dv = self.map.lookup(dv);
+        let name = Value::new_datum(dv);
+        self.add_affixes(name, &footnotes)
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum VisChild {
+    Extension(VisualizationExtension),
+    UserSource(UserSource),
+    SourceVariable(SourceVariable),
+    DerivedVariable(DerivedVariable),
+    CategoricalDomain(CategoricalDomain),
+    Graph(Graph),
+    LabelFrame(LabelFrame),
+    Container(Container),
+    Style(Style),
+    LayerController(LayerController),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename = "extension", rename_all = "camelCase")]
+struct VisualizationExtension {
+    #[serde(rename = "@showGridline")]
+    show_gridline: Option<bool>,
+}
+
+#[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")]
+    source_name: 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>>,
+
+    #[serde(default, rename = "extension")]
+    extensions: Vec<VariableExtension>,
+    format: Option<Format>,
+    string_format: Option<StringFormat>,
+}
+
+impl SourceVariable {
+    fn decode(
+        &self,
+        data: &HashMap<String, HashMap<String, Vec<DataValue>>>,
+        series: &HashMap<&str, Series>,
+    ) -> Result<Series, ()> {
+        let label_series = if let Some(label_variable) = &self.label_variable {
+            let Some(label_series) = series.get(label_variable.references.as_str()) else {
+                return Err(());
+            };
+            Some(label_series)
+        } else {
+            None
+        };
+
+        let Some(data) = data
+            .get(&self.source)
+            .and_then(|source| source.get(&self.source_name))
+        else {
+            todo!()
+        };
+        let mut map = Map::new();
+        let (format, affixes) = map.remap_formats(&self.format, &self.string_format);
+        let mut data = data.clone();
+        if !map.0.is_empty() {
+            map.apply(&mut data);
+        } else if let Some(label_series) = label_series {
+            map.insert_labels(&data, label_series, format);
+        }
+        Ok(Series::new(self.id.clone(), data, map)
+            .with_format(format)
+            .with_affixes(affixes)
+            .with_label(self.label.clone()))
+    }
+}
+
+#[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,
+    #[serde(default, rename = "extension")]
+    extensions: Vec<VariableExtension>,
+    format: Option<Format>,
+    string_format: Option<StringFormat>,
+    #[serde(default, rename = "valueMapEntry")]
+    value_map: Vec<ValueMapEntry>,
+}
+
+impl DerivedVariable {
+    fn decode(&self, series: &HashMap<&str, Series>) -> Result<Series, ()> {
+        let mut values = if self.value == "constant(0)" {
+            let n_values = if let Some(series) = series.values().next() {
+                series.values.len()
+            } else {
+                return Err(());
+            };
+            (0..n_values)
+                .map(|_| DataValue {
+                    index: Some(0.0),
+                    value: Datum::Number(Some(0.0)),
+                })
+                .collect()
+        } 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 Err(());
+            };
+            dependency.values.clone()
+        } else {
+            unreachable!()
+        };
+        let mut map = Map::new();
+        map.remap_vmes(&self.value_map);
+        map.apply(&mut values);
+        map.remap_formats(&self.format, &self.string_format);
+        if values
+            .iter()
+            .all(|value| value.value.is_string_and(|s| s.is_empty()))
+        {
+            values.clear();
+        }
+        Ok(Series::new(self.id.clone(), values, map))
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename = "extension", rename_all = "camelCase")]
+struct VariableExtension {
+    #[serde(rename = "@from")]
+    from: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct UserSource {
+    #[serde(rename = "@id")]
+    id: String,
+
+    #[serde(rename = "@missing")]
+    missing: Option<Missing>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct CategoricalDomain {
+    variable_reference: VariableReference,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct VariableReference {
+    #[serde(rename = "@ref")]
+    reference: String,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum Missing {
+    Listwise,
+    Pairwise,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct StringFormat {
+    #[serde(default, rename = "relabel")]
+    relabels: Vec<Relabel>,
+    #[serde(default, rename = "affix")]
+    affixes: Vec<Affix>,
+}
+
+#[derive(Deserialize, Debug, Default)]
+#[serde(rename_all = "camelCase")]
+struct Format {
+    #[serde(rename = "@baseFormat")]
+    base_format: Option<BaseFormat>,
+    #[serde(rename = "@errorCharacter")]
+    error_character: Option<char>,
+    #[serde(rename = "@separatorChars")]
+    separator_chars: Option<String>,
+    #[serde(rename = "@mdyOrder")]
+    mdy_order: Option<MdyOrder>,
+    #[serde(rename = "@showYear")]
+    show_year: Option<bool>,
+    #[serde(rename = "@showQuarter")]
+    show_quarter: Option<bool>,
+    #[serde(rename = "@quarterPrefix")]
+    quarter_prefix: Option<String>,
+    #[serde(rename = "@quarterSuffix")]
+    quarter_suffix: Option<String>,
+    #[serde(rename = "@yearAbbreviation")]
+    year_abbreviation: Option<bool>,
+    #[serde(rename = "@showMonth")]
+    show_month: Option<bool>,
+    #[serde(rename = "@monthFormat")]
+    month_format: Option<MonthFormat>,
+    #[serde(rename = "@dayPadding")]
+    day_padding: Option<bool>,
+    #[serde(rename = "@dayOfMonthPadding")]
+    day_of_month_padding: Option<bool>,
+    #[serde(rename = "@showWeek")]
+    show_week: Option<bool>,
+    #[serde(rename = "@weekPadding")]
+    week_padding: Option<bool>,
+    #[serde(rename = "@weekSuffix")]
+    week_suffix: Option<String>,
+    #[serde(rename = "@showDayOfWeek")]
+    show_day_of_week: Option<bool>,
+    #[serde(rename = "@dayOfWeekAbbreviation")]
+    day_of_week_abbreviation: Option<bool>,
+    #[serde(rename = "hourPadding")]
+    hour_padding: Option<bool>,
+    #[serde(rename = "minutePadding")]
+    minute_padding: Option<bool>,
+    #[serde(rename = "secondPadding")]
+    second_padding: Option<bool>,
+    #[serde(rename = "@showDay")]
+    show_day: Option<bool>,
+    #[serde(rename = "@showHour")]
+    show_hour: Option<bool>,
+    #[serde(rename = "@showMinute")]
+    show_minute: Option<bool>,
+    #[serde(rename = "@showSecond")]
+    show_second: Option<bool>,
+    #[serde(rename = "@showMillis")]
+    show_millis: Option<bool>,
+    #[serde(rename = "@dayType")]
+    day_type: Option<DayType>,
+    #[serde(rename = "@hourFormat")]
+    hour_format: Option<HourFormat>,
+    #[serde(rename = "@minimumIntegerDigits")]
+    minimum_integer_digits: Option<usize>,
+    #[serde(rename = "@maximumFractionDigits")]
+    maximum_fraction_digits: Option<i64>,
+    #[serde(rename = "@minimumFractionDigits")]
+    minimum_fraction_digits: Option<usize>,
+    #[serde(rename = "@useGrouping")]
+    use_grouping: Option<bool>,
+    #[serde(rename = "@scientific")]
+    scientific: Option<Scientific>,
+    #[serde(rename = "@small")]
+    small: Option<f64>,
+    #[serde(default, rename = "@prefix")]
+    prefix: String,
+    #[serde(default, rename = "@suffix")]
+    suffix: String,
+    #[serde(rename = "@tryStringsAsNumbers")]
+    try_strings_as_numbers: Option<bool>,
+    #[serde(rename = "@negativesOutside")]
+    negatives_outside: Option<bool>,
+    #[serde(default, rename = "relabel")]
+    relabels: Vec<Relabel>,
+    #[serde(default, rename = "affix")]
+    affixes: Vec<Affix>,
+}
+
+impl Format {
+    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 = "@minimumIntegerDigits")]
+    minimum_integer_digits: Option<i64>,
+    #[serde(rename = "@maximumFractionDigits")]
+    maximum_fraction_digits: Option<i64>,
+    #[serde(rename = "@minimumFractionDigits")]
+    minimum_fraction_digits: Option<i64>,
+    #[serde(rename = "@useGrouping")]
+    use_grouping: Option<bool>,
+    #[serde(rename = "@scientific")]
+    scientific: Option<Scientific>,
+    #[serde(rename = "@small")]
+    small: Option<f64>,
+    #[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 = "@separatorChars")]
+    separator_chars: Option<String>,
+    #[serde(rename = "@mdyOrder")]
+    mdy_order: Option<MdyOrder>,
+    #[serde(rename = "@showYear")]
+    show_year: Option<bool>,
+    #[serde(rename = "@showQuarter")]
+    show_quarter: Option<bool>,
+    #[serde(rename = "@quarterPrefix")]
+    quarter_prefix: Option<String>,
+    #[serde(rename = "@quarterSuffix")]
+    quarter_suffix: Option<String>,
+    #[serde(rename = "@yearAbbreviation")]
+    year_abbreviation: Option<bool>,
+    #[serde(rename = "@showMonth")]
+    show_month: Option<bool>,
+    #[serde(rename = "@monthFormat")]
+    month_format: Option<MonthFormat>,
+    #[serde(rename = "@dayPadding")]
+    day_padding: Option<bool>,
+    #[serde(rename = "@dayOfMonthPadding")]
+    day_of_month_padding: Option<bool>,
+    #[serde(rename = "@showWeek")]
+    show_week: Option<bool>,
+    #[serde(rename = "@weekPadding")]
+    week_padding: Option<bool>,
+    #[serde(rename = "@weekSuffix")]
+    week_suffix: Option<String>,
+    #[serde(rename = "@showDayOfWeek")]
+    show_day_of_week: Option<bool>,
+    #[serde(rename = "@dayOfWeekAbbreviation")]
+    day_of_week_abbreviation: Option<bool>,
+    #[serde(rename = "hourPadding")]
+    hour_padding: Option<bool>,
+    #[serde(rename = "minutePadding")]
+    minute_padding: Option<bool>,
+    #[serde(rename = "secondPadding")]
+    second_padding: Option<bool>,
+    #[serde(rename = "@showDay")]
+    show_day: Option<bool>,
+    #[serde(rename = "@showHour")]
+    show_hour: Option<bool>,
+    #[serde(rename = "@showMinute")]
+    show_minute: Option<bool>,
+    #[serde(rename = "@showSecond")]
+    show_second: Option<bool>,
+    #[serde(rename = "@showMillis")]
+    show_millis: Option<bool>,
+    #[serde(rename = "@dayType")]
+    day_type: Option<DayType>,
+    #[serde(rename = "@hourFormat")]
+    hour_format: Option<HourFormat>,
+    #[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)
+    }
+}
+
+impl DateTimeFormat {
+    fn decode(&self) -> crate::format::Format {
+        SignificantDateTimeFormat::from(self).decode()
+    }
+}
+
+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 = "@dayPadding")]
+    day_padding: Option<bool>,
+    #[serde(rename = "hourPadding")]
+    hour_padding: Option<bool>,
+    #[serde(rename = "minutePadding")]
+    minute_padding: Option<bool>,
+    #[serde(rename = "secondPadding")]
+    second_padding: Option<bool>,
+    #[serde(rename = "@showDay")]
+    show_day: Option<bool>,
+    #[serde(rename = "@showHour")]
+    show_hour: Option<bool>,
+    #[serde(rename = "@showMinute")]
+    show_minute: Option<bool>,
+    #[serde(rename = "@showSecond")]
+    show_second: Option<bool>,
+    #[serde(rename = "@showMillis")]
+    show_millis: Option<bool>,
+    #[serde(rename = "@showYear")]
+    show_year: Option<bool>,
+    #[serde(default, rename = "affix")]
+    affixes: Vec<Affix>,
+}
+
+impl ElapsedTimeFormat {
+    fn decode(&self) -> crate::format::Format {
+        let type_ = 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)
+    }
+}
+
+#[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 DayType {
+    Month,
+    Year,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum HourFormat {
+    #[serde(rename = "AMPM")]
+    AmPm,
+    #[serde(rename = "AS_24")]
+    As24,
+    #[serde(rename = "AS_12")]
+    As12,
+}
+
+#[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 {
+    /// The footnote number as a natural number: 1 for the first footnote, 2 for
+    /// the second, and so on.
+    #[serde(rename = "@definesReference")]
+    defines_reference: u64,
+
+    /// Position for the footnote label.
+    #[serde(rename = "@position")]
+    position: Position,
+
+    /// Whether the affix is a suffix (true) or a prefix (false).
+    #[serde(rename = "@suffix")]
+    suffix: bool,
+
+    /// The text of the suffix or prefix. Typically a letter, e.g. `a` for
+    /// footnote 1, `b` for footnote 2, ...
+    #[serde(rename = "@value")]
+    value: String,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum Position {
+    Subscript,
+    Superscript,
+}
+
+#[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>,
+
+    /// Not used.
+    #[serde(rename = "@color2")]
+    color2: Option<Color>,
+
+    /// Normally 0. The value -90 causes inner column or outer row labels to be
+    /// rotated vertically.
+    #[serde(rename = "@labelAngle")]
+    label_angle: Option<f64>,
+
+    #[serde(rename = "@border-bottom")]
+    border_bottom: Option<Border>,
+
+    #[serde(rename = "@border-top")]
+    border_top: Option<Border>,
+
+    #[serde(rename = "@border-left")]
+    border_left: Option<Border>,
+
+    #[serde(rename = "@border-right")]
+    border_right: Option<Border>,
+
+    #[serde(rename = "@border-bottom-color")]
+    border_bottom_color: Option<Color>,
+
+    #[serde(rename = "@border-top-color")]
+    border_top_color: Option<Color>,
+
+    #[serde(rename = "@border-left-color")]
+    border_left_color: Option<Color>,
+
+    #[serde(rename = "@border-right-color")]
+    border_right_color: Option<Color>,
+
+    #[serde(rename = "@font-family")]
+    font_family: Option<String>,
+
+    #[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 = "@margin-bottom")]
+    margin_bottom: Option<Length>,
+
+    #[serde(rename = "@margin-top")]
+    margin_top: Option<Length>,
+
+    #[serde(rename = "@margin-left")]
+    margin_left: Option<Length>,
+
+    #[serde(rename = "@margin-right")]
+    margin_right: Option<Length>,
+
+    #[serde(rename = "@textAlignment")]
+    text_alignment: Option<TextAlignment>,
+
+    #[serde(rename = "@labelLocationHorizontal")]
+    label_location_horizontal: Option<LabelLocation>,
+
+    #[serde(rename = "@labelLocationVertical")]
+    label_location_vertical: Option<LabelLocation>,
+
+    #[serde(rename = "@size")]
+    size: Option<String>,
+
+    #[serde(rename = "@width")]
+    width: Option<String>,
+
+    #[serde(rename = "@visible")]
+    visible: Option<bool>,
+
+    #[serde(rename = "@decimal-offset")]
+    decimal_offset: Option<Length>,
+}
+
+impl Style {
+    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(_)) => 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 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 {
+                font_style.bold = weight.is_bold();
+            }
+            if let Some(style) = fg.font_style {
+                font_style.italic = style.is_italic();
+            }
+            if let Some(underline) = fg.font_underline {
+                font_style.underline = underline.is_underline();
+            }
+            if let Some(color) = 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()
+                {
+                    font_style.size = size;
+                } else {
+                    // XXX warn?
+                }
+            }
+            if let Some(alignment) = fg.text_alignment {
+                cell_style.horz_align = alignment.as_horz_align(fg.decimal_offset);
+            }
+            if let Some(label_local_vertical) = fg.label_location_vertical {
+                cell_style.vert_align = label_local_vertical.into();
+            }
+        }
+        if let Some(bg) = bg {
+            if let Some(color) = 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)]
+#[serde(rename_all = "camelCase")]
+enum Border {
+    Solid,
+    Thick,
+    Thin,
+    Double,
+    None,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum FontWeight {
+    Regular,
+    Bold,
+}
+
+impl FontWeight {
+    fn is_bold(&self) -> bool {
+        *self == Self::Bold
+    }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum FontStyle {
+    Regular,
+    Italic,
+}
+
+impl FontStyle {
+    fn is_italic(&self) -> bool {
+        *self == Self::Italic
+    }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum FontUnderline {
+    None,
+    Underline,
+}
+
+impl FontUnderline {
+    fn is_underline(&self) -> bool {
+        *self == Self::Underline
+    }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum TextAlignment {
+    Left,
+    Right,
+    Center,
+    Decimal,
+    Mixed,
+}
+
+impl TextAlignment {
+    fn as_horz_align(&self, decimal_offset: Option<Length>) -> Option<HorzAlign> {
+        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_f64(),
+                decimal: Dot,
+            }),
+            TextAlignment::Mixed => None,
+        }
+    }
+}
+
+#[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 {
+    #[serde(rename = "@id")]
+    id: Option<String>,
+
+    #[serde(rename = "@cellStyle")]
+    cell_style: Ref<Style>,
+
+    #[serde(rename = "@style")]
+    style: Ref<Style>,
+
+    #[serde(rename = "location")]
+    locations: Vec<Location>,
+    coordinates: Coordinates,
+    faceting: Faceting,
+    facet_layout: FacetLayout,
+    interval: Interval,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Coordinates;
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Location {
+    /// The part of the table being located.
+    #[serde(rename = "@part")]
+    part: Part,
+
+    /// How the location is determined.
+    #[serde(rename = "@method")]
+    method: Method,
+
+    /// Minimum size.
+    #[serde(rename = "@min")]
+    min: Option<Length>,
+
+    /// Maximum size.
+    #[serde(rename = "@max")]
+    max: Option<Length>,
+
+    /// An element to attach to. Required when method is attach or same, not
+    /// observed otherwise.
+    #[serde(rename = "@target")]
+    target: Option<String>,
+
+    #[serde(rename = "@value")]
+    value: Option<String>,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum Part {
+    Height,
+    Width,
+    Top,
+    Bottom,
+    Left,
+    Right,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum Method {
+    SizeToContent,
+    Attach,
+    Fixed,
+    Same,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Faceting {
+    #[serde(rename = "@id")]
+    id: Option<String>,
+
+    #[serde(default)]
+    layers1: Vec<Layer>,
+    cross: Cross,
+    #[serde(default)]
+    layers2: Vec<Layer>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Cross {
+    #[serde(rename = "$value")]
+    children: Vec<CrossChild>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum CrossChild {
+    /// No dimensions along this axis.
+    Unity,
+    /// Dimensions along this axis.
+    Nest(Nest),
+}
+
+impl CrossChild {
+    fn variables(&self) -> &[VariableReference] {
+        match self {
+            CrossChild::Unity => &[],
+            CrossChild::Nest(nest) => &nest.variable_references,
+        }
+    }
+}
+
+#[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,
+
+    #[serde(rename = "@visible")]
+    visible: Option<bool>,
+
+    #[serde(rename = "@titleVisible")]
+    title_visible: Option<bool>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct FacetLayout {
+    table_layout: TableLayout,
+    #[serde(rename = "$value")]
+    children: Vec<FacetLayoutChild>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum FacetLayoutChild {
+    SetCellProperties(SetCellProperties),
+    FacetLevel(FacetLevel),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct TableLayout {
+    #[serde(rename = "@verticalTitlesInCorner")]
+    vertical_titles_in_corner: bool,
+
+    #[serde(rename = "@style")]
+    style: Option<Ref<Style>>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct SetCellProperties {
+    #[serde(rename = "@id")]
+    id: Option<String>,
+
+    #[serde(rename = "@applyToConverse")]
+    apply_to_converse: Option<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),
+    IntersectWhere(IntersectWhere),
+    Alternating,
+    #[serde(other)]
+    Empty,
+}
+
+#[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")]
+struct IntersectWhere {
+    #[serde(rename = "@variable")]
+    variable: String,
+
+    #[serde(rename = "@variable2")]
+    variable2: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum Set {
+    SetStyle(SetStyle),
+    SetFrameStyle(SetFrameStyle),
+    SetFormat(SetFormat),
+    SetMetaData(SetMetaData),
+}
+
+#[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 SetMetaData {
+    #[serde(rename = "@target")]
+    target: Ref<Graph>,
+
+    #[serde(rename = "@key")]
+    key: String,
+
+    #[serde(rename = "@value")]
+    value: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct SetFormat {
+    #[serde(rename = "@target")]
+    target: String,
+
+    #[serde(rename = "@reset")]
+    reset: Option<bool>,
+
+    #[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 SetFrameStyle {
+    #[serde(rename = "@style")]
+    style: Ref<Style>,
+
+    #[serde(rename = "@target")]
+    target: Ref<MajorTicks>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Interval {
+    #[serde(rename = "@id")]
+    id: Option<String>,
+
+    #[serde(rename = "@style")]
+    style: Ref<Style>,
+
+    labeling: Labeling,
+    footnotes: Option<Footnotes>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Labeling {
+    #[serde(rename = "@id")]
+    id: Option<String>,
+
+    #[serde(rename = "@style")]
+    style: Option<Ref<Style>>,
+
+    #[serde(rename = "@variable")]
+    variable: String,
+
+    #[serde(default)]
+    children: Vec<LabelingChild>,
+}
+
+impl Labeling {
+    fn decode_format_map<'a>(
+        &self,
+        series: &'a HashMap<&str, Series>,
+    ) -> (Option<&'a Series>, HashMap<i64, crate::format::Format>) {
+        let mut map = HashMap::new();
+        let mut cell_format = None;
+        for child in &self.children {
+            if let LabelingChild::Formatting(formatting) = child {
+                cell_format = series.get(formatting.variable.as_str());
+                for mapping in &formatting.mappings {
+                    if let Some(format) = &mapping.format {
+                        map.insert(mapping.from, format.decode());
+                    }
+                }
+            }
+        }
+        (cell_format, map)
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum LabelingChild {
+    Formatting(Formatting),
+    Format(Format),
+    Footnotes(Footnotes),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Formatting {
+    #[serde(rename = "@variable")]
+    variable: String,
+
+    mappings: Vec<FormatMapping>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct FormatMapping {
+    #[serde(rename = "@from")]
+    from: i64,
+
+    format: Option<Format>,
+}
+
+#[derive(Clone, Debug, Default)]
+struct Footnote {
+    content: String,
+    marker: Option<String>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Footnotes {
+    #[serde(rename = "@superscript")]
+    superscript: Option<bool>,
+
+    #[serde(rename = "@variable")]
+    variable: String,
+
+    #[serde(default, rename = "footnoteMapping")]
+    mappings: Vec<FootnoteMapping>,
+}
+
+impl Footnotes {
+    fn decode(&self, dst: &mut BTreeMap<usize, Footnote>) {
+        for f in &self.mappings {
+            dst.entry(f.defines_reference.get() - 1)
+                .or_default()
+                .content = f.to.clone();
+        }
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct FootnoteMapping {
+    #[serde(rename = "@definesReference")]
+    defines_reference: NonZeroUsize,
+
+    #[serde(rename = "@from")]
+    from: i64,
+
+    #[serde(rename = "@to")]
+    to: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct FacetLevel {
+    #[serde(rename = "@id")]
+    id: Option<String>,
+
+    #[serde(rename = "@level")]
+    level: usize,
+
+    #[serde(rename = "@gap")]
+    gap: Option<Length>,
+    axis: Axis,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Axis {
+    #[serde(rename = "@style")]
+    style: Ref<Style>,
+
+    label: Option<Label>,
+    major_ticks: MajorTicks,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct MajorTicks {
+    #[serde(rename = "@id")]
+    id: String,
+
+    #[serde(rename = "@labelAngle")]
+    label_angle: f64,
+
+    #[serde(rename = "@length")]
+    length: Length,
+
+    #[serde(rename = "@style")]
+    style: Ref<Style>,
+
+    #[serde(rename = "@tickFrameStyle")]
+    tick_frame_style: Ref<Style>,
+
+    #[serde(rename = "@labelFrequency")]
+    label_frequency: Option<i64>,
+
+    #[serde(rename = "@stagger")]
+    stagger: Option<bool>,
+
+    gridline: Option<Gridline>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Gridline {
+    #[serde(rename = "@style")]
+    style: Ref<Style>,
+
+    #[serde(rename = "@zOrder")]
+    z_order: i64,
+}
+
+#[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,
+}
+
+impl Label {
+    fn text(&self) -> &[Text] {
+        match &self.child {
+            LabelChild::Text(texts) => texts.as_slice(),
+            LabelChild::DescriptionGroup(_) => &[],
+        }
+    }
+
+    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_area(fg, bg, area_style);
+    }
+}
+#[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>),
+    DescriptionGroup(DescriptionGroup),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Text {
+    #[serde(rename = "@usesReference")]
+    uses_reference: Option<NonZeroUsize>,
+
+    #[serde(rename = "@definesReference")]
+    defines_reference: Option<NonZeroUsize>,
+
+    #[serde(rename = "@position")]
+    position: Option<Position>,
+
+    #[serde(rename = "@style")]
+    style: Option<Ref<Style>>,
+
+    #[serde(default, rename = "$text")]
+    text: String,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct DescriptionGroup {
+    #[serde(rename = "@target")]
+    target: Ref<Faceting>,
+
+    #[serde(rename = "@separator")]
+    separator: Option<String>,
+
+    #[serde(rename = "$value")]
+    children: Vec<DescriptionGroupChild>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+enum DescriptionGroupChild {
+    Description(Description),
+    Text(Text),
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Description {
+    #[serde(rename = "@name")]
+    name: Name,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "camelCase")]
+enum Name {
+    Variable,
+    Value,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct LabelFrame {
+    #[serde(rename = "@id")]
+    id: Option<String>,
+
+    #[serde(rename = "@style")]
+    style: Option<Ref<Style>>,
+
+    #[serde(rename = "location")]
+    locations: Vec<Location>,
+
+    label: Option<Label>,
+    paragraph: Option<Paragraph>,
+}
+
+impl LabelFrame {
+    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
+        }
+    }
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Paragraph;
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct Container {
+    #[serde(rename = "@style")]
+    style: Ref<Style>,
+
+    #[serde(default, rename = "extension")]
+    extensions: Option<ContainerExtension>,
+    #[serde(default)]
+    locations: Vec<Location>,
+    #[serde(default)]
+    label_frames: Vec<LabelFrame>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename = "extension", rename_all = "camelCase")]
+struct ContainerExtension {
+    #[serde(rename = "@combinedFootnotes")]
+    combined_footnotes: Option<bool>,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct LayerController {
+    #[serde(rename = "@target")]
+    target: Option<Ref<Label>>,
+}
diff --git a/rust/pspp/src/spv/read/light.rs b/rust/pspp/src/spv/read/light.rs
new file mode 100644 (file)
index 0000000..6726673
--- /dev/null
@@ -0,0 +1,1666 @@
+use std::{
+    fmt::Debug,
+    io::{Read, Seek},
+    ops::Deref,
+    str::FromStr,
+    sync::Arc,
+};
+
+use binrw::{
+    BinRead, BinResult, Endian, Error as BinError, VecArgs, binread, error::ContextExt,
+    io::TakeSeekExt,
+};
+use chrono::DateTime;
+use displaydoc::Display;
+use encoding_rs::{Encoding, WINDOWS_1252};
+use enum_map::{EnumMap, enum_map};
+
+use crate::{
+    format::{
+        CC, Decimal, Decimals, Epoch, F40, Format, NumberStyle, Settings, Type, UncheckedFormat,
+        Width,
+    },
+    output::pivot::{
+        self, AreaStyle, Axis2, Axis3, BoxBorder, Color, FootnoteMarkerPosition,
+        FootnoteMarkerType, Footnotes, Group, HeadingRegion, HorzAlign, LabelPosition, Look,
+        PivotTable, PivotTableMetadata, PivotTableStyle, PrecomputedIndex, RowColBorder, RowParity,
+        StringValue, Stroke, TemplateValue, ValueStyle, VariableValue, VertAlign, parse_bool,
+    },
+    settings::Show,
+};
+
+#[derive(Debug, Display, thiserror::Error)]
+pub enum LightError {
+    /// Expected {expected} dimensions along axes, found {actual} dimensions ({n_layers} layers + {n_rows} rows + {n_columns} columns).
+    WrongAxisCount {
+        expected: usize,
+        actual: usize,
+        n_layers: usize,
+        n_rows: usize,
+        n_columns: usize,
+    },
+
+    /// Invalid dimension index {index} in table with {n} dimensions.
+    InvalidDimensionIndex { index: usize, n: usize },
+
+    /// Dimension with index {0} appears twice in table axes.
+    DuplicateDimensionIndex(usize),
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+pub struct LightTable {
+    header: Header,
+    #[br(args(header.version))]
+    titles: Titles,
+    #[br(parse_with(parse_vec), args(header.version))]
+    footnotes: Vec<Footnote>,
+    #[br(args(header.version))]
+    areas: Areas,
+    #[br(parse_with(parse_counted))]
+    borders: Borders,
+    #[br(parse_with(parse_counted))]
+    print_settings: PrintSettings,
+    #[br(if(header.version == Version::V3), parse_with(parse_counted))]
+    table_settings: TableSettings,
+    #[br(if(header.version == Version::V1), temp)]
+    _ts: Option<Counted<Sponge>>,
+    #[br(args(header.version))]
+    formats: Formats,
+    #[br(parse_with(parse_vec), args(header.version))]
+    dimensions: Vec<Dimension>,
+    axes: Axes,
+    #[br(parse_with(parse_vec), args(header.version))]
+    cells: Vec<Cell>,
+}
+
+impl LightTable {
+    fn decode_look(&self, encoding: &'static Encoding) -> Look {
+        Look {
+            name: self.table_settings.table_look.decode_optional(encoding),
+            hide_empty: self.table_settings.omit_empty,
+            row_label_position: if self.table_settings.show_row_labels_in_corner {
+                LabelPosition::Corner
+            } else {
+                LabelPosition::Nested
+            },
+            heading_widths: enum_map! {
+                HeadingRegion::Rows => self.header.min_row_heading_width as isize..=self.header.max_row_heading_width as isize,
+                HeadingRegion::Columns => self.header.min_column_heading_width as isize..=self.header.max_column_heading_width as isize,
+            },
+            footnote_marker_type: if self.table_settings.show_alphabetic_markers {
+                FootnoteMarkerType::Alphabetic
+            } else {
+                FootnoteMarkerType::Numeric
+            },
+            footnote_marker_position: if self.table_settings.footnote_marker_subscripts {
+                FootnoteMarkerPosition::Subscript
+            } else {
+                FootnoteMarkerPosition::Superscript
+            },
+            areas: self.areas.decode(encoding),
+            borders: self.borders.decode(),
+            print_all_layers: self.print_settings.alll_layers,
+            paginate_layers: self.print_settings.paginate_layers,
+            shrink_to_fit: enum_map! {
+                Axis2::X => self.print_settings.fit_width,
+                Axis2::Y => self.print_settings.fit_length,
+            },
+            top_continuation: self.print_settings.top_continuation,
+            bottom_continuation: self.print_settings.bottom_continuation,
+            continuation: self
+                .print_settings
+                .continuation_string
+                .decode_optional(encoding),
+            n_orphan_lines: self.print_settings.n_orphan_lines,
+        }
+    }
+
+    pub fn decode(&self) -> Result<PivotTable, LightError> {
+        let encoding = self.formats.encoding();
+
+        let n1 = self.formats.n1();
+        let n2 = self.formats.n2();
+        let n3 = self.formats.n3();
+        let n3_inner = n3.and_then(|n3| n3.inner.as_ref());
+        let y1 = self.formats.y1();
+        let footnotes = self
+            .footnotes
+            .iter()
+            .map(|f| f.decode(encoding, &Footnotes::new()))
+            .collect();
+        let cells = self
+            .cells
+            .iter()
+            .map(|cell| {
+                (
+                    PrecomputedIndex(cell.index as usize),
+                    cell.value.decode(encoding, &footnotes),
+                )
+            })
+            .collect::<Vec<_>>();
+        let dimensions = self
+            .dimensions
+            .iter()
+            .map(|d| {
+                let mut root = Group::new(d.name.decode(encoding, &footnotes))
+                    .with_show_label(!d.hide_dim_label);
+                for category in &d.categories {
+                    category.decode(encoding, &footnotes, &mut root);
+                }
+                pivot::Dimension {
+                    presentation_order: (0..root.len()).collect(), /*XXX*/
+                    root,
+                    hide_all_labels: d.hide_all_labels,
+                }
+            })
+            .collect::<Vec<_>>();
+        let pivot_table = PivotTable::new(self.axes.decode(dimensions)?)
+            .with_style(PivotTableStyle {
+                look: Arc::new(self.decode_look(encoding)),
+                rotate_inner_column_labels: self.header.rotate_inner_column_labels,
+                rotate_outer_row_labels: self.header.rotate_outer_row_labels,
+                show_grid_lines: self.borders.show_grid_lines,
+                show_title: n1.map_or(true, |x1| x1.show_title != 10),
+                show_caption: n1.map_or(true, |x1| x1.show_caption),
+                show_values: n1.map_or(None, |x1| x1.show_values),
+                show_variables: n1.map_or(None, |x1| x1.show_variables),
+                sizing: self.table_settings.sizing.decode(
+                    &self.formats.column_widths,
+                    n2.map_or(&[], |x2| &x2.row_heights),
+                ),
+                settings: Settings {
+                    epoch: self.formats.y0.epoch(),
+                    decimal: self.formats.y0.decimal(),
+                    leading_zero: y1.map_or(false, |y1| y1.include_leading_zero),
+                    ccs: self.formats.custom_currency.decode(encoding),
+                },
+                grouping: {
+                    let grouping = self.formats.y0.grouping;
+                    b",.' ".contains(&grouping).then_some(grouping as char)
+                },
+                small: n3.map_or(0.0, |n3| n3.small),
+                weight_format: F40,
+            })
+            .with_metadata(PivotTableMetadata {
+                command_local: y1.map(|y1| y1.command_local.decode(encoding)),
+                command_c: y1.map(|y1| y1.command.decode(encoding)),
+                language: y1.map(|y1| y1.language.decode(encoding)),
+                locale: y1.map(|y1| y1.locale.decode(encoding)),
+                dataset: n3_inner.and_then(|strings| strings.dataset.decode_optional(encoding)),
+                datafile: n3_inner.and_then(|strings| strings.datafile.decode_optional(encoding)),
+                date: n3_inner.and_then(|inner| {
+                    if inner.date != 0 {
+                        DateTime::from_timestamp(inner.date as i64, 0).map(|dt| dt.naive_utc())
+                    } else {
+                        None
+                    }
+                }),
+                title: Some(Box::new(self.titles.title.decode(encoding, &footnotes))),
+                subtype: Some(Box::new(self.titles.subtype.decode(encoding, &footnotes))),
+                corner_text: self
+                    .titles
+                    .corner_text
+                    .as_ref()
+                    .map(|corner| Box::new(corner.decode(encoding, &footnotes))),
+                caption: self
+                    .titles
+                    .caption
+                    .as_ref()
+                    .map(|caption| Box::new(caption.decode(encoding, &footnotes))),
+                notes: self.table_settings.notes.decode_optional(encoding),
+            })
+            .with_footnotes(footnotes)
+            .with_data(cells);
+        Ok(pivot_table)
+    }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Header {
+    #[br(magic = b"\x01\0")]
+    version: Version,
+    #[br(parse_with(parse_bool), temp)]
+    _x0: bool,
+    #[br(parse_with(parse_bool), temp)]
+    _x1: bool,
+    #[br(parse_with(parse_bool))]
+    rotate_inner_column_labels: bool,
+    #[br(parse_with(parse_bool))]
+    rotate_outer_row_labels: bool,
+    #[br(parse_with(parse_bool), temp)]
+    _x2: bool,
+    #[br(temp)]
+    _x3: i32,
+    min_column_heading_width: u32,
+    max_column_heading_width: u32,
+    min_row_heading_width: u32,
+    max_row_heading_width: u32,
+    #[br(temp)]
+    _table_id: i64,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+enum Version {
+    #[br(magic = 1u32)]
+    V1,
+    #[br(magic = 3u32)]
+    V3,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct Titles {
+    #[br(args(version))]
+    title: Value,
+    #[br(temp)]
+    _1: Optional<One>,
+    #[br(args(version))]
+    subtype: Value,
+    #[br(temp)]
+    _2: Optional<One>,
+    #[br(magic = b'1')]
+    #[br(args(version))]
+    user_title: Value,
+    #[br(temp)]
+    _3: Optional<One>,
+    #[br(parse_with(parse_explicit_optional), args(version))]
+    corner_text: Option<Value>,
+    #[br(parse_with(parse_explicit_optional), args(version))]
+    caption: Option<Value>,
+}
+
+#[binread]
+#[br(little, magic = 1u8)]
+#[derive(Debug)]
+struct One;
+
+#[binread]
+#[br(little, magic = 0u8)]
+#[derive(Debug)]
+struct Zero;
+
+#[binrw::parser(reader, endian)]
+pub fn parse_explicit_optional<'a, T, A>(args: A, ...) -> BinResult<Option<T>>
+where
+    T: BinRead<Args<'a> = A>,
+{
+    let byte = <u8>::read_options(reader, endian, ())?;
+    match byte {
+        b'1' => Ok(Some(T::read_options(reader, endian, args)?)),
+        b'X' => Ok(None),
+        _ => Err(BinError::NoVariantMatch {
+            pos: reader.stream_position()? - 1,
+        }),
+    }
+}
+
+#[binrw::parser(reader, endian)]
+pub(super) fn parse_vec<T, A>(inner: A, ...) -> BinResult<Vec<T>>
+where
+    for<'a> T: BinRead<Args<'a> = A>,
+    A: Clone,
+    T: 'static,
+{
+    let count = u32::read_options(reader, endian, ())? as usize;
+    <Vec<T>>::read_options(reader, endian, VecArgs { count, inner })
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct Footnote {
+    #[br(args(version))]
+    text: Value,
+    #[br(parse_with(parse_explicit_optional))]
+    #[br(args(version))]
+    marker: Option<Value>,
+    show: i32,
+}
+
+impl Footnote {
+    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Footnote {
+        pivot::Footnote::new(self.text.decode(encoding, footnotes))
+            .with_marker(self.marker.as_ref().map(|m| m.decode(encoding, footnotes)))
+            .with_show(self.show > 0)
+    }
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct Areas {
+    #[br(temp)]
+    _1: Optional<Zero>,
+    #[br(args(version))]
+    areas: [Area; 8],
+}
+
+impl Areas {
+    fn decode(&self, encoding: &'static Encoding) -> EnumMap<pivot::Area, AreaStyle> {
+        EnumMap::from_fn(|area| {
+            let index = match area {
+                pivot::Area::Title => 0,
+                pivot::Area::Caption => 1,
+                pivot::Area::Footer => 2,
+                pivot::Area::Corner => 3,
+                pivot::Area::Labels(Axis2::X) => 4,
+                pivot::Area::Labels(Axis2::Y) => 5,
+                pivot::Area::Data(_) => 6,
+                pivot::Area::Layers => 7,
+            };
+            let data_row = match area {
+                pivot::Area::Data(row) => row,
+                _ => RowParity::default(),
+            };
+            self.areas[index].decode(encoding, data_row)
+        })
+    }
+}
+
+#[binrw::parser(reader, endian)]
+fn parse_color() -> BinResult<Color> {
+    let pos = reader.stream_position()?;
+    let string = U32String::read_options(reader, endian, ())?;
+    let string = string.decode(WINDOWS_1252);
+    if string.is_empty() {
+        Ok(Color::BLACK)
+    } else {
+        Color::from_str(&string).map_err(|error| binrw::Error::Custom {
+            pos,
+            err: Box::new(error),
+        })
+    }
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct Area {
+    #[br(temp)]
+    _index: u8,
+    #[br(magic = b'1')]
+    typeface: U32String,
+    size: f32,
+    style: i32,
+    #[br(parse_with(parse_bool))]
+    underline: bool,
+    halign: i32,
+    valign: i32,
+    #[br(parse_with(parse_color))]
+    fg: Color,
+    #[br(parse_with(parse_color))]
+    bg: Color,
+    #[br(parse_with(parse_bool))]
+    alternate: bool,
+    #[br(parse_with(parse_color))]
+    alt_fg: Color,
+    #[br(parse_with(parse_color))]
+    alt_bg: Color,
+    #[br(if(version == Version::V3))]
+    margins: Margins,
+}
+
+impl Area {
+    fn decode(&self, encoding: &'static Encoding, data_row: RowParity) -> AreaStyle {
+        AreaStyle {
+            cell_style: pivot::CellStyle {
+                horz_align: match self.halign {
+                    0 => Some(HorzAlign::Center),
+                    2 => Some(HorzAlign::Left),
+                    4 => Some(HorzAlign::Right),
+                    _ => None,
+                },
+                vert_align: match self.valign {
+                    0 => VertAlign::Middle,
+                    3 => VertAlign::Bottom,
+                    _ => VertAlign::Top,
+                },
+                margins: enum_map! {
+                    Axis2::X => [self.margins.left_margin, self.margins.right_margin],
+                    Axis2::Y => [self.margins.top_margin, self.margins.bottom_margin]
+                },
+            },
+            font_style: pivot::FontStyle {
+                bold: (self.style & 1) != 0,
+                italic: (self.style & 2) != 0,
+                underline: self.underline,
+                font: self.typeface.decode(encoding),
+                fg: if data_row == RowParity::Odd && self.alternate {
+                    self.alt_fg
+                } else {
+                    self.fg
+                },
+                bg: if data_row == RowParity::Odd && self.alternate {
+                    self.alt_bg
+                } else {
+                    self.bg
+                },
+                size: (self.size / 1.33) as i32,
+            },
+        }
+    }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug, Default)]
+struct Margins {
+    left_margin: i32,
+    right_margin: i32,
+    top_margin: i32,
+    bottom_margin: i32,
+}
+
+#[binread]
+#[br(big)]
+#[derive(Debug)]
+struct Borders {
+    #[br(magic(1u32), parse_with(parse_vec))]
+    borders: Vec<Border>,
+
+    #[br(parse_with(parse_bool))]
+    show_grid_lines: bool,
+
+    #[br(temp, magic(b"\0\0\0"))]
+    _1: (),
+}
+
+impl Borders {
+    fn decode(&self) -> EnumMap<pivot::Border, pivot::BorderStyle> {
+        let mut borders = pivot::Border::default_borders();
+        for border in &self.borders {
+            if let Some((border, style)) = border.decode() {
+                borders[border] = style;
+            } else {
+                // warning
+            }
+        }
+        borders
+    }
+}
+
+#[binread]
+#[br(big)]
+#[derive(Debug)]
+struct Border {
+    #[br(map(|index: u32| index as usize))]
+    index: usize,
+    stroke: i32,
+    color: u32,
+}
+
+impl Border {
+    fn decode(&self) -> Option<(pivot::Border, pivot::BorderStyle)> {
+        let border = match self.index {
+            0 => pivot::Border::Title,
+            1 => pivot::Border::OuterFrame(BoxBorder::Left),
+            2 => pivot::Border::OuterFrame(BoxBorder::Top),
+            3 => pivot::Border::OuterFrame(BoxBorder::Right),
+            4 => pivot::Border::OuterFrame(BoxBorder::Bottom),
+            5 => pivot::Border::InnerFrame(BoxBorder::Left),
+            6 => pivot::Border::InnerFrame(BoxBorder::Top),
+            7 => pivot::Border::InnerFrame(BoxBorder::Right),
+            8 => pivot::Border::InnerFrame(BoxBorder::Bottom),
+            9 => pivot::Border::DataLeft,
+            10 => pivot::Border::DataLeft,
+            11 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+            12 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+            13 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+            14 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+            15 => pivot::Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+            16 => pivot::Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+            17 => pivot::Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+            18 => pivot::Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+            _ => return None,
+        };
+
+        let stroke = match self.stroke {
+            0 => Stroke::None,
+            2 => Stroke::Dashed,
+            3 => Stroke::Thick,
+            4 => Stroke::Thin,
+            6 => Stroke::Double,
+            _ => Stroke::Solid,
+        };
+
+        let color = Color::new(
+            (self.color >> 16) as u8,
+            (self.color >> 8) as u8,
+            self.color as u8,
+        )
+        .with_alpha((self.color >> 24) as u8);
+
+        Some((border, pivot::BorderStyle { stroke, color }))
+    }
+}
+
+#[binread]
+#[br(big)]
+#[derive(Debug)]
+struct PrintSettings {
+    #[br(magic = b"\0\0\0\x01")]
+    #[br(parse_with(parse_bool))]
+    alll_layers: bool,
+    #[br(parse_with(parse_bool))]
+    paginate_layers: bool,
+    #[br(parse_with(parse_bool))]
+    fit_width: bool,
+    #[br(parse_with(parse_bool))]
+    fit_length: bool,
+    #[br(parse_with(parse_bool))]
+    top_continuation: bool,
+    #[br(parse_with(parse_bool))]
+    bottom_continuation: bool,
+    #[br(map(|n: u32| n as usize))]
+    n_orphan_lines: usize,
+    continuation_string: U32String,
+}
+
+#[binread]
+#[br(big)]
+#[derive(Debug, Default)]
+struct TableSettings {
+    #[br(temp, magic = 1u32)]
+    _x5: i32,
+    current_layer: i32,
+    #[br(parse_with(parse_bool))]
+    omit_empty: bool,
+    #[br(parse_with(parse_bool))]
+    show_row_labels_in_corner: bool,
+    #[br(parse_with(parse_bool))]
+    show_alphabetic_markers: bool,
+    #[br(parse_with(parse_bool))]
+    footnote_marker_subscripts: bool,
+    #[br(temp)]
+    _x6: u8,
+    #[br(big, parse_with(parse_counted))]
+    sizing: Sizing,
+    notes: U32String,
+    table_look: U32String,
+    #[br(temp)]
+    _sponge: Sponge,
+}
+
+#[binread]
+#[br(big)]
+#[derive(Debug, Default)]
+struct Sizing {
+    #[br(parse_with(parse_vec))]
+    row_breaks: Vec<u32>,
+    #[br(parse_with(parse_vec))]
+    column_breaks: Vec<u32>,
+    #[br(parse_with(parse_vec))]
+    row_keeps: Vec<(i32, i32)>,
+    #[br(parse_with(parse_vec))]
+    column_keeps: Vec<(i32, i32)>,
+    #[br(parse_with(parse_vec))]
+    row_point_keeps: Vec<[i32; 3]>,
+    #[br(parse_with(parse_vec))]
+    column_point_keeps: Vec<[i32; 3]>,
+}
+
+impl Sizing {
+    fn decode(
+        &self,
+        column_widths: &[i32],
+        row_heights: &[i32],
+    ) -> EnumMap<Axis2, Option<Box<pivot::Sizing>>> {
+        fn decode_axis(
+            widths: &[i32],
+            breaks: &[u32],
+            keeps: &[(i32, i32)],
+        ) -> Option<Box<pivot::Sizing>> {
+            if widths.is_empty() && breaks.is_empty() && keeps.is_empty() {
+                None
+            } else {
+                Some(Box::new(pivot::Sizing {
+                    widths: widths.into(),
+                    breaks: breaks.into_iter().map(|b| *b as usize).collect(),
+                    keeps: keeps
+                        .into_iter()
+                        .map(|(low, high)| *low as usize..*high as usize)
+                        .collect(),
+                }))
+            }
+        }
+
+        enum_map! {
+            Axis2::X => decode_axis(column_widths, &self.column_breaks, &self.column_keeps),
+            Axis2::Y => decode_axis(row_heights, &self.row_breaks, &self.row_keeps),
+        }
+    }
+}
+
+#[binread]
+#[derive(Default)]
+pub(super) struct U32String {
+    #[br(parse_with(parse_vec))]
+    string: Vec<u8>,
+}
+
+impl U32String {
+    pub(super) fn decode(&self, encoding: &'static Encoding) -> String {
+        if let Ok(string) = str::from_utf8(&self.string) {
+            string.into()
+        } else {
+            encoding
+                .decode_without_bom_handling(&self.string)
+                .0
+                .into_owned()
+        }
+    }
+    pub(super) fn decode_optional(&self, encoding: &'static Encoding) -> Option<String> {
+        let string = self.decode(encoding);
+        if !string.is_empty() {
+            Some(string)
+        } else {
+            None
+        }
+    }
+}
+
+impl Debug for U32String {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let s = self.string.iter().map(|c| *c as char).collect::<String>();
+        write!(f, "{s:?}")
+    }
+}
+
+#[derive(Clone, Debug, Default)]
+struct Counted<T>(T);
+
+impl<T> Deref for Counted<T> {
+    type Target = T;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl<T> BinRead for Counted<T>
+where
+    T: BinRead,
+{
+    type Args<'a> = T::Args<'a>;
+
+    fn read_options<R: Read + Seek>(
+        reader: &mut R,
+        endian: binrw::Endian,
+        args: Self::Args<'_>,
+    ) -> BinResult<Self> {
+        let count = u32::read_options(reader, endian, ())? as u64;
+        let start = reader.stream_position()?;
+        let end = start + count;
+        let mut inner = reader.take_seek(count);
+        let result = <T>::read_options(&mut inner, Endian::Little, args)?;
+        let pos = inner.stream_position()?;
+        if pos != end {
+            let consumed = pos - start;
+            return Err(binrw::Error::Custom {
+                pos,
+                err: Box::new(format!(
+                    "counted data not exhausted (consumed {consumed} bytes out of {count})"
+                )),
+            });
+        }
+        Ok(Self(result))
+    }
+}
+
+#[binrw::parser(reader, endian)]
+fn parse_counted<T, A>(args: A, ...) -> BinResult<T>
+where
+    for<'a> T: BinRead<Args<'a> = A>,
+    A: Clone,
+    T: 'static,
+{
+    Ok(<Counted<T>>::read_options(reader, endian, args)?.0)
+}
+
+/// `BinRead` for `Option<T>` always requires the value to be there.  This
+/// instead tries to read it and falls back to None if there's no match.
+#[derive(Clone, Debug)]
+struct Optional<T>(Option<T>);
+
+impl<T> Default for Optional<T> {
+    fn default() -> Self {
+        Self(None)
+    }
+}
+
+impl<T> Deref for Optional<T> {
+    type Target = Option<T>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl<T> BinRead for Optional<T>
+where
+    T: BinRead,
+{
+    type Args<'a> = T::Args<'a>;
+
+    fn read_options<R: Read + Seek>(
+        reader: &mut R,
+        endian: binrw::Endian,
+        args: Self::Args<'_>,
+    ) -> BinResult<Self> {
+        let start = reader.stream_position()?;
+        let result = <T>::read_options(reader, endian, args).ok();
+        if result.is_none() {
+            reader.seek(std::io::SeekFrom::Start(start))?;
+        }
+        Ok(Self(result))
+    }
+}
+
+#[binread]
+#[br(little)]
+#[br(import(version: Version))]
+#[derive(Debug)]
+struct Formats {
+    #[br(parse_with(parse_vec))]
+    column_widths: Vec<i32>,
+    locale: U32String,
+    current_layer: i32,
+    #[br(temp, parse_with(parse_bool))]
+    _x7: bool,
+    #[br(temp, parse_with(parse_bool))]
+    _x8: bool,
+    #[br(temp, parse_with(parse_bool))]
+    _x9: bool,
+    y0: Y0,
+    custom_currency: CustomCurrency,
+    #[br(if(version == Version::V1))]
+    v1: Counted<Optional<N0>>,
+    #[br(if(version == Version::V3))]
+    v3: Option<Counted<FormatsV3>>,
+}
+
+impl Formats {
+    fn y1(&self) -> Option<&Y1> {
+        self.v1
+            .as_ref()
+            .map(|n0| &n0.y1)
+            .or_else(|| self.v3.as_ref().map(|v3| &v3.n3.y1))
+    }
+
+    fn n1(&self) -> Option<&N1> {
+        self.v3.as_ref().map(|v3| &v3.n1_n2.x1)
+    }
+
+    fn n2(&self) -> Option<&N2> {
+        self.v3.as_ref().map(|v3| &v3.n1_n2.x2)
+    }
+
+    fn n3(&self) -> Option<&N3> {
+        self.v3.as_ref().map(|v3| &v3.n3)
+    }
+
+    fn charset(&self) -> Option<&U32String> {
+        self.y1().map(|y1| &y1.charset)
+    }
+
+    fn encoding(&self) -> &'static Encoding {
+        // XXX We should probably warn for unknown encodings below
+        if let Some(charset) = self.charset()
+            && let Some(encoding) = Encoding::for_label(&charset.string)
+        {
+            encoding
+        } else if let Ok(locale) = str::from_utf8(&self.locale.string)
+            && let Some(dot) = locale.find('.')
+            && let Some(encoding) = Encoding::for_label(locale[dot + 1..].as_bytes())
+        {
+            encoding
+        } else {
+            WINDOWS_1252
+        }
+    }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct FormatsV3 {
+    #[br(parse_with(parse_counted))]
+    n1_n2: N1N2,
+    #[br(parse_with(parse_counted))]
+    n3: N3,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N1N2 {
+    x1: N1,
+    #[br(parse_with(parse_counted))]
+    x2: N2,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N0 {
+    #[br(temp)]
+    _bytes: [u8; 14],
+    y1: Y1,
+    _y2: Y2,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Y1 {
+    command: U32String,
+    command_local: U32String,
+    language: U32String,
+    charset: U32String,
+    locale: U32String,
+    #[br(temp, parse_with(parse_bool))]
+    _x10: bool,
+    #[br(parse_with(parse_bool))]
+    include_leading_zero: bool,
+    #[br(temp, parse_with(parse_bool))]
+    _x12: bool,
+    #[br(temp, parse_with(parse_bool))]
+    _x13: bool,
+    _y0: Y0,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Y2 {
+    custom_currency: CustomCurrency,
+    missing: u8,
+    #[br(temp, parse_with(parse_bool))]
+    _x17: bool,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N1 {
+    #[br(temp, parse_with(parse_bool))]
+    _x14: bool,
+    show_title: u8,
+    #[br(temp, parse_with(parse_bool))]
+    _x16: bool,
+    _lang: u8,
+    #[br(parse_with(parse_show))]
+    show_variables: Option<Show>,
+    #[br(parse_with(parse_show))]
+    show_values: Option<Show>,
+    #[br(temp)]
+    _x18: i32,
+    #[br(temp)]
+    _x19: i32,
+    #[br(temp)]
+    _zeros: [u8; 17],
+    #[br(temp, parse_with(parse_bool))]
+    _x20: bool,
+    #[br(parse_with(parse_bool))]
+    show_caption: bool,
+}
+
+#[binrw::parser(reader, endian)]
+fn parse_show() -> BinResult<Option<Show>> {
+    match <u8>::read_options(reader, endian, ())? {
+        0 => Ok(None),
+        1 => Ok(Some(Show::Value)),
+        2 => Ok(Some(Show::Label)),
+        3 => Ok(Some(Show::Both)),
+        _ => {
+            // XXX warn about invalid value
+            Ok(None)
+        }
+    }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N2 {
+    #[br(parse_with(parse_vec))]
+    row_heights: Vec<i32>,
+    #[br(parse_with(parse_vec))]
+    style_map: Vec<(i64, i16)>,
+    #[br(parse_with(parse_vec))]
+    styles: Vec<StylePair>,
+    #[br(parse_with(parse_counted))]
+    tail: Optional<[u8; 8]>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N3 {
+    #[br(temp, magic = b"\x01\0")]
+    _x21: u8,
+    #[br(magic = b"\0\0\0")]
+    y1: Y1,
+    small: f64,
+    #[br(magic = 1u8, temp)]
+    _one: (),
+    inner: Optional<N3Inner>,
+    y2: Y2,
+    #[br(temp)]
+    _tail: Optional<N3Tail>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N3Inner {
+    dataset: U32String,
+    datafile: U32String,
+    notes_unexpanded: U32String,
+    date: i32,
+    #[br(magic = 0u32, temp)]
+    _tail: (),
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct N3Tail {
+    #[br(temp)]
+    _x22: i32,
+    #[br(temp, assert(_zero == 0))]
+    _zero: i32,
+    #[br(temp, assert(_x25.is_none_or(|x25| x25 == 0 || x25 == 1)))]
+    _x25: Optional<u8>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Y0 {
+    epoch: i32,
+    decimal: u8,
+    grouping: u8,
+}
+
+impl Y0 {
+    fn epoch(&self) -> Epoch {
+        if (1000..=9999).contains(&self.epoch) {
+            Epoch(self.epoch)
+        } else {
+            Epoch::default()
+        }
+    }
+
+    fn decimal(&self) -> Decimal {
+        // XXX warn about bad decimal point?
+        Decimal::try_from(self.decimal as char).unwrap_or_default()
+    }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct CustomCurrency {
+    #[br(parse_with(parse_vec))]
+    ccs: Vec<U32String>,
+}
+
+impl CustomCurrency {
+    fn decode(&self, encoding: &'static Encoding) -> EnumMap<CC, Option<Box<NumberStyle>>> {
+        let mut ccs = EnumMap::default();
+        for (cc, string) in enum_iterator::all().zip(&self.ccs) {
+            if let Ok(style) = NumberStyle::from_str(&string.decode(encoding)) {
+                ccs[cc] = Some(Box::new(style));
+            } else {
+                // XXX warning
+            }
+        }
+        ccs
+    }
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueNumber {
+    #[br(parse_with(parse_explicit_optional), args(version))]
+    mods: Option<ValueMods>,
+    #[br(parse_with(parse_format))]
+    format: Format,
+    x: f64,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueVarNumber {
+    #[br(parse_with(parse_explicit_optional), args(version))]
+    mods: Option<ValueMods>,
+    #[br(parse_with(parse_format))]
+    format: Format,
+    x: f64,
+    var_name: U32String,
+    value_label: U32String,
+    #[br(parse_with(parse_show))]
+    show: Option<Show>,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueText {
+    local: U32String,
+    #[br(parse_with(parse_explicit_optional), args(version))]
+    mods: Option<ValueMods>,
+    id: U32String,
+    c: U32String,
+    #[br(parse_with(parse_bool))]
+    fixed: bool,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueString {
+    #[br(parse_with(parse_explicit_optional), args(version))]
+    mods: Option<ValueMods>,
+    #[br(parse_with(parse_format))]
+    format: Format,
+    value_label: U32String,
+    var_name: U32String,
+    #[br(parse_with(parse_show))]
+    show: Option<Show>,
+    s: U32String,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueVarName {
+    #[br(parse_with(parse_explicit_optional), args(version))]
+    mods: Option<ValueMods>,
+    var_name: U32String,
+    var_label: U32String,
+    #[br(parse_with(parse_show))]
+    show: Option<Show>,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueFixedText {
+    local: U32String,
+    #[br(parse_with(parse_explicit_optional), args(version))]
+    mods: Option<ValueMods>,
+    id: U32String,
+    c: U32String,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueTemplate {
+    #[br(parse_with(parse_explicit_optional), args(version))]
+    mods: Option<ValueMods>,
+    template: U32String,
+    #[br(parse_with(parse_vec), args(version))]
+    args: Vec<Argument>,
+}
+
+#[derive(Debug)]
+enum Value {
+    Number(ValueNumber),
+    VarNumber(ValueVarNumber),
+    Text(ValueText),
+    String(ValueString),
+    VarName(ValueVarName),
+    FixedText(ValueFixedText),
+    Template(ValueTemplate),
+}
+
+impl BinRead for Value {
+    type Args<'a> = (Version,);
+
+    fn read_options<R: Read + Seek>(
+        reader: &mut R,
+        endian: Endian,
+        args: Self::Args<'_>,
+    ) -> BinResult<Self> {
+        let start = reader.stream_position()?;
+        let kind = loop {
+            let x = <u8>::read_options(reader, endian, ())?;
+            if x != 0 {
+                break x;
+            }
+        };
+        match kind {
+            1 => ValueNumber::read_options(reader, endian, args).map(Self::Number),
+            2 => Ok(Self::VarNumber(ValueVarNumber::read_options(
+                reader, endian, args,
+            )?)),
+            3 => Ok(Self::Text(ValueText::read_options(reader, endian, args)?)),
+            4 => Ok(Self::String(ValueString::read_options(
+                reader, endian, args,
+            )?)),
+            5 => Ok(Self::VarName(ValueVarName::read_options(
+                reader, endian, args,
+            )?)),
+            6 => Ok(Self::FixedText(ValueFixedText::read_options(
+                reader, endian, args,
+            )?)),
+            b'1' | b'X' => {
+                reader.seek(std::io::SeekFrom::Current(-1))?;
+                Ok(Self::Template(ValueTemplate::read_options(
+                    reader, endian, args,
+                )?))
+            }
+            _ => Err(BinError::NoVariantMatch { pos: start }),
+        }
+        .map_err(|e| e.with_message(format!("while parsing Value starting at offset {start:#x}")))
+    }
+}
+
+pub(super) fn decode_format(raw: u32) -> Format {
+    if raw == 0 || raw == 0x10000 || raw == 1 {
+        return Format::new(Type::F, 40, 2).unwrap();
+    }
+
+    let raw_type = (raw >> 16) as u16;
+    let type_ = if raw_type >= 40 {
+        Type::F
+    } else if let Ok(type_) = Type::try_from(raw_type) {
+        type_
+    } else {
+        // XXX warn
+        Type::F
+    };
+    let w = ((raw >> 8) & 0xff) as Width;
+    let d = raw as Decimals;
+
+    UncheckedFormat::new(type_, w, d).fix()
+}
+
+#[binrw::parser(reader, endian)]
+fn parse_format() -> BinResult<Format> {
+    Ok(decode_format(u32::read_options(reader, endian, ())?))
+}
+
+impl ValueNumber {
+    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
+        pivot::Value::new_number_with_format((self.x != -f64::MAX).then_some(self.x), self.format)
+            .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+    }
+}
+
+impl ValueVarNumber {
+    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
+        pivot::Value::new_number_with_format((self.x != -f64::MAX).then_some(self.x), self.format)
+            .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+            .with_value_label(self.value_label.decode_optional(encoding))
+            .with_variable_name(Some(self.var_name.decode(encoding)))
+            .with_show_value_label(self.show)
+    }
+}
+
+impl ValueText {
+    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
+        pivot::Value::new_general_text(
+            self.local.decode(encoding),
+            self.c.decode(encoding),
+            self.id.decode(encoding),
+            !self.fixed,
+        )
+        .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+    }
+}
+
+impl ValueString {
+    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
+        pivot::Value::new(pivot::ValueInner::String(StringValue {
+            s: self.s.decode(encoding),
+            hex: self.format.type_() == Type::AHex,
+            show: self.show,
+            var_name: self.var_name.decode_optional(encoding),
+            value_label: self.value_label.decode_optional(encoding),
+        }))
+        .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+    }
+}
+
+impl ValueVarName {
+    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
+        pivot::Value::new(pivot::ValueInner::Variable(VariableValue {
+            show: self.show,
+            var_name: self.var_name.decode(encoding),
+            variable_label: self.var_label.decode_optional(encoding),
+        }))
+        .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+    }
+}
+impl ValueFixedText {
+    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
+        pivot::Value::new_general_text(
+            self.local.decode(encoding),
+            self.c.decode(encoding),
+            self.id.decode(encoding),
+            false,
+        )
+        .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+    }
+}
+
+impl ValueTemplate {
+    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
+        pivot::Value::new(pivot::ValueInner::Template(TemplateValue {
+            args: self
+                .args
+                .iter()
+                .map(|argument| argument.decode(encoding, footnotes))
+                .collect(),
+            localized: self.template.decode(encoding),
+            id: self
+                .mods
+                .as_ref()
+                .and_then(|mods| mods.template_id(encoding)),
+        }))
+        .with_styling(ValueMods::decode_optional(&self.mods, encoding, footnotes))
+    }
+}
+
+impl Value {
+    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> pivot::Value {
+        match self {
+            Value::Number(number) => number.decode(encoding, footnotes),
+            Value::VarNumber(var_number) => var_number.decode(encoding, footnotes),
+            Value::Text(text) => text.decode(encoding, footnotes),
+            Value::String(string) => string.decode(encoding, footnotes),
+            Value::VarName(var_name) => var_name.decode(encoding, footnotes),
+            Value::FixedText(fixed_text) => fixed_text.decode(encoding, footnotes),
+            Value::Template(template) => template.decode(encoding, footnotes),
+        }
+    }
+}
+
+#[derive(Debug)]
+struct Argument(Vec<Value>);
+
+impl BinRead for Argument {
+    type Args<'a> = (Version,);
+
+    fn read_options<R: Read + Seek>(
+        reader: &mut R,
+        endian: Endian,
+        (version,): (Version,),
+    ) -> BinResult<Self> {
+        let count = u32::read_options(reader, endian, ())? as usize;
+        if count == 0 {
+            Ok(Self(vec![Value::read_options(reader, endian, (version,))?]))
+        } else {
+            let zero = u32::read_options(reader, endian, ())?;
+            assert_eq!(zero, 0);
+            let values = <Vec<_>>::read_options(
+                reader,
+                endian,
+                VecArgs {
+                    count,
+                    inner: (version,),
+                },
+            )?;
+            Ok(Self(values))
+        }
+    }
+}
+
+impl Argument {
+    fn decode(
+        &self,
+        encoding: &'static Encoding,
+        footnotes: &pivot::Footnotes,
+    ) -> Vec<pivot::Value> {
+        self.0
+            .iter()
+            .map(|value| value.decode(encoding, footnotes))
+            .collect()
+    }
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct ValueMods {
+    #[br(parse_with(parse_vec))]
+    refs: Vec<i16>,
+    #[br(parse_with(parse_vec))]
+    subscripts: Vec<U32String>,
+    #[br(if(version == Version::V1))]
+    v1: Option<ValueModsV1>,
+    #[br(if(version == Version::V3), parse_with(parse_counted))]
+    v3: ValueModsV3,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug, Default)]
+struct ValueModsV1 {
+    #[br(temp, magic(0u8), assert(_1 == 1 || _1 == 2))]
+    _1: i32,
+    #[br(temp)]
+    _0: Optional<Zero>,
+    #[br(temp)]
+    _1: Optional<Zero>,
+    #[br(temp)]
+    _2: i32,
+    #[br(temp)]
+    _3: Optional<Zero>,
+    #[br(temp)]
+    _4: Optional<Zero>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug, Default)]
+struct ValueModsV3 {
+    #[br(parse_with(parse_counted))]
+    template_string: Optional<TemplateString>,
+    style_pair: StylePair,
+}
+
+impl ValueMods {
+    fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> ValueStyle {
+        let font_style =
+            self.v3
+                .style_pair
+                .font_style
+                .as_ref()
+                .map(|font_style| pivot::FontStyle {
+                    bold: font_style.bold,
+                    italic: font_style.italic,
+                    underline: font_style.underline,
+                    font: font_style.typeface.decode(encoding),
+                    fg: font_style.fg,
+                    bg: font_style.bg,
+                    size: (font_style.size as i32) * 4 / 3,
+                });
+        let cell_style = self.v3.style_pair.cell_style.as_ref().map(|cell_style| {
+            pivot::CellStyle {
+                horz_align: match cell_style.halign {
+                    0 => Some(HorzAlign::Center),
+                    2 => Some(HorzAlign::Left),
+                    4 => Some(HorzAlign::Right),
+                    6 => Some(HorzAlign::Decimal {
+                        offset: cell_style.decimal_offset,
+                        decimal: Decimal::Dot, /*XXX*/
+                    }),
+                    _ => None,
+                },
+                vert_align: match cell_style.valign {
+                    0 => VertAlign::Middle,
+                    3 => VertAlign::Bottom,
+                    _ => VertAlign::Top,
+                },
+                margins: enum_map! {
+                    Axis2::X => [cell_style.left_margin as i32, cell_style.right_margin as i32],
+                    Axis2::Y => [cell_style.top_margin as i32, cell_style.bottom_margin as i32],
+                },
+            }
+        });
+        ValueStyle {
+            cell_style,
+            font_style,
+            subscripts: self.subscripts.iter().map(|s| s.decode(encoding)).collect(),
+            footnotes: self
+                .refs
+                .iter()
+                .flat_map(|index| footnotes.get(*index as usize))
+                .cloned()
+                .collect(),
+        }
+    }
+    fn decode_optional(
+        mods: &Option<Self>,
+        encoding: &'static Encoding,
+        footnotes: &pivot::Footnotes,
+    ) -> Option<Box<pivot::ValueStyle>> {
+        mods.as_ref()
+            .map(|mods| Box::new(mods.decode(encoding, footnotes)))
+    }
+    fn template_id(&self, encoding: &'static Encoding) -> Option<String> {
+        self.v3
+            .template_string
+            .as_ref()
+            .and_then(|template_string| template_string.id.as_ref())
+            .map(|s| s.decode(encoding))
+    }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct TemplateString {
+    #[br(parse_with(parse_counted), temp)]
+    _sponge: Sponge,
+    #[br(parse_with(parse_explicit_optional))]
+    id: Option<U32String>,
+}
+
+#[derive(Debug, Default)]
+struct Sponge;
+
+impl BinRead for Sponge {
+    type Args<'a> = ();
+
+    fn read_options<R: Read + Seek>(reader: &mut R, _endian: Endian, _args: ()) -> BinResult<Self> {
+        let mut buf = [0; 32];
+        while reader.read(&mut buf)? > 0 {}
+        Ok(Self)
+    }
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug, Default)]
+struct StylePair {
+    #[br(parse_with(parse_explicit_optional))]
+    font_style: Option<FontStyle>,
+    #[br(parse_with(parse_explicit_optional))]
+    cell_style: Option<CellStyle>,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct FontStyle {
+    #[br(parse_with(parse_bool))]
+    bold: bool,
+    #[br(parse_with(parse_bool))]
+    italic: bool,
+    #[br(parse_with(parse_bool))]
+    underline: bool,
+    #[br(parse_with(parse_bool))]
+    show: bool,
+    #[br(parse_with(parse_color))]
+    fg: Color,
+    #[br(parse_with(parse_color))]
+    bg: Color,
+    typeface: U32String,
+    size: u8,
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct CellStyle {
+    halign: i32,
+    valign: i32,
+    decimal_offset: f64,
+    left_margin: i16,
+    right_margin: i16,
+    top_margin: i16,
+    bottom_margin: i16,
+}
+
+#[binread]
+#[br(little)]
+#[br(import(version: Version))]
+#[derive(Debug)]
+struct Dimension {
+    #[br(args(version))]
+    name: Value,
+    #[br(temp)]
+    _x1: u8,
+    #[br(temp)]
+    _x2: u8,
+    #[br(temp)]
+    _x3: u32,
+    #[br(parse_with(parse_bool))]
+    hide_dim_label: bool,
+    #[br(parse_with(parse_bool))]
+    hide_all_labels: bool,
+    #[br(magic(1u8), temp)]
+    _dim_index: i32,
+    #[br(parse_with(parse_vec), args(version))]
+    categories: Vec<Category>,
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct Category {
+    #[br(args(version))]
+    name: Value,
+    #[br(args(version))]
+    child: Child,
+}
+
+impl Category {
+    fn decode(&self, encoding: &'static Encoding, footnotes: &Footnotes, group: &mut pivot::Group) {
+        let name = self.name.decode(encoding, footnotes);
+        match &self.child {
+            Child::Leaf { leaf_index: _ } => {
+                group.push(pivot::Leaf::new(name));
+            }
+            Child::Group {
+                merge: true,
+                subcategories,
+            } => {
+                for subcategory in subcategories {
+                    subcategory.decode(encoding, footnotes, group);
+                }
+            }
+            Child::Group {
+                merge: false,
+                subcategories,
+            } => {
+                let mut subgroup = Group::new(name).with_label_shown();
+                for subcategory in subcategories {
+                    subcategory.decode(encoding, footnotes, &mut subgroup);
+                }
+                group.push(subgroup);
+            }
+        }
+    }
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+enum Child {
+    Leaf {
+        #[br(magic(0u16), parse_with(parse_bool), temp)]
+        _x24: bool,
+        #[br(magic(b"\x02\0\0\0"))]
+        leaf_index: u32,
+        #[br(magic(0u32), temp)]
+        _tail: (),
+    },
+    Group {
+        #[br(parse_with(parse_bool))]
+        merge: bool,
+        #[br(temp, magic(b"\0\x01"))]
+        _x23: i32,
+        #[br(magic(-1i32), parse_with(parse_vec), args(version))]
+        subcategories: Vec<Box<Category>>,
+    },
+}
+
+#[binread]
+#[br(little)]
+#[derive(Debug)]
+struct Axes {
+    #[br(temp)]
+    n_layers: u32,
+    #[br(temp)]
+    n_rows: u32,
+    #[br(temp)]
+    n_columns: u32,
+    #[br(count(n_layers))]
+    layers: Vec<u32>,
+    #[br(count(n_rows))]
+    rows: Vec<u32>,
+    #[br(count(n_columns))]
+    columns: Vec<u32>,
+}
+
+impl Axes {
+    fn decode(
+        &self,
+        dimensions: Vec<pivot::Dimension>,
+    ) -> Result<Vec<(Axis3, pivot::Dimension)>, LightError> {
+        let n = self.layers.len() + self.rows.len() + self.columns.len();
+        if n != dimensions.len() {
+            /* XXX warn
+                return Err(LightError::WrongAxisCount {
+                    expected: dimensions.len(),
+                    actual: n,
+                    n_layers: self.layers.len(),
+                    n_rows: self.rows.len(),
+                    n_columns: self.columns.len(),
+            });*/
+            return Ok(dimensions
+                .into_iter()
+                .map(|dimension| (Axis3::Y, dimension))
+                .collect());
+        }
+
+        fn axis_dims(axis: Axis3, dimensions: &[u32]) -> impl Iterator<Item = (Axis3, usize)> {
+            dimensions.iter().map(move |d| (axis, *d as usize))
+        }
+
+        let mut axes = vec![None; n];
+        for (axis, index) in axis_dims(Axis3::Z, &self.layers)
+            .chain(axis_dims(Axis3::Y, &self.rows))
+            .chain(axis_dims(Axis3::X, &self.columns))
+        {
+            if index >= n {
+                return Err(LightError::InvalidDimensionIndex { index, n });
+            } else if axes[index].is_some() {
+                return Err(LightError::DuplicateDimensionIndex(index));
+            }
+            axes[index] = Some(axis);
+        }
+        Ok(axes
+            .into_iter()
+            .map(|axis| axis.unwrap())
+            .zip(dimensions)
+            .collect())
+    }
+}
+
+#[binread]
+#[br(little, import(version: Version))]
+#[derive(Debug)]
+struct Cell {
+    index: u64,
+    #[br(if(version == Version::V1), temp)]
+    _zero: Optional<Zero>,
+    #[br(args(version))]
+    value: Value,
+}
index 08aa5f3c274f461595909b379ba419b9b0e899a4..93ae30b3d00df9add4e25b6689e1e20c1262d26b 100644 (file)
@@ -40,12 +40,13 @@ use crate::{
             Footnotes, Group, HeadingRegion, HorzAlign, LabelPosition, Leaf, PivotTable,
             RowColBorder, RowParity, Stroke, Value, ValueInner, ValueStyle, VertAlign,
         },
-        spv::html::Document,
     },
     settings::Show,
+    spv::read::html::Document,
     util::ToSmallString,
 };
 
+/// SPSS viewer (SPV) file writer.
 pub struct Writer<W>
 where
     W: Write + Seek,
@@ -61,6 +62,8 @@ impl<W> Writer<W>
 where
     W: Write + Seek,
 {
+    /// Creates a new `Writer` to write an SPV file to underlying stream
+    /// `writer`.
     pub fn for_writer(writer: W) -> Self {
         let mut writer = ZipWriter::new(writer);
         writer
@@ -76,11 +79,27 @@ where
         }
     }
 
+    /// Returns this `Writer` with `page_setup` set up to be written with the
+    /// next call to [write](Writer::write).
+    ///
+    /// Page setup is only significant if it is written before the first call to
+    /// [write](Writer::writer).
     pub fn with_page_setup(mut self, page_setup: PageSetup) -> Self {
         self.set_page_setup(page_setup);
         self
     }
 
+    /// Sets `page_setup` to be written with the next call to
+    /// [write](Writer::write).
+    ///
+    /// Page setup is only significant if it is written before the first call to
+    /// [write](Writer::writer).
+    pub fn set_page_setup(&mut self, page_setup: PageSetup) {
+        self.page_setup = Some(page_setup);
+    }
+
+    /// Closes the underlying file and returns the inner writer and the final
+    /// I/O result.
     pub fn close(mut self) -> ZipResult<W> {
         self.writer
             .start_file("META-INF/MANIFEST.MF", SimpleFileOptions::default())?;
@@ -88,10 +107,6 @@ where
         self.writer.finish()
     }
 
-    pub fn set_page_setup(&mut self, page_setup: PageSetup) {
-        self.page_setup = Some(page_setup);
-    }
-
     fn page_break_before(&mut self) -> bool {
         let page_break_before = self.needs_page_break;
         self.needs_page_break = false;
@@ -523,7 +538,7 @@ impl PivotTable {
 
 impl<W> Writer<W>
 where
-    W: Write + Seek + 'static,
+    W: Write + Seek,
 {
     pub fn write(&mut self, item: &Item) {
         if item.details.is_page_break() {