work
authorBen Pfaff <blp@cs.stanford.edu>
Tue, 30 Dec 2025 16:36:41 +0000 (08:36 -0800)
committerBen Pfaff <blp@cs.stanford.edu>
Tue, 30 Dec 2025 16:36:41 +0000 (08:36 -0800)
rust/pspp/src/output.rs
rust/pspp/src/output/drivers/csv.rs
rust/pspp/src/output/drivers/html.rs
rust/pspp/src/output/drivers/text.rs
rust/pspp/src/spv/read.rs
rust/pspp/src/spv/read/structure.rs [new file with mode: 0644]
rust/pspp/src/spv/write.rs

index 76b45cb9f980a992c39d3a7c75b0f9b4042c02cc..edcecfd6fb02118f209ab8b39626c4a96af1e0db 100644 (file)
@@ -196,7 +196,7 @@ impl Heading {
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq)]
 pub enum ItemKind {
-    Chart,
+    Graph,
     Image,
     Heading,
     Message,
@@ -208,7 +208,7 @@ pub enum ItemKind {
 impl ItemKind {
     pub fn as_str(&self) -> &'static str {
         match self {
-            ItemKind::Chart => "chart",
+            ItemKind::Graph => "graph",
             ItemKind::Image => "image",
             ItemKind::Heading => "heading",
             ItemKind::Message => "message",
@@ -227,7 +227,7 @@ impl Display for ItemKind {
 
 #[derive(Clone, Debug, Serialize)]
 pub enum Details {
-    Chart,
+    Graph,
     Image(#[serde(skip_serializing)] ImageSurface),
     Heading(Heading),
     Message(Box<Diagnostic>),
@@ -285,7 +285,7 @@ impl Details {
 
     pub fn command_name(&self) -> Option<&String> {
         match self {
-            Details::Chart
+            Details::Graph
             | Details::Image(_)
             | Details::Heading(_)
             | Details::Message(_)
@@ -297,7 +297,7 @@ impl Details {
 
     pub fn label(&self) -> Cow<'static, str> {
         match self {
-            Details::Chart => Cow::from("chart"),
+            Details::Graph => Cow::from("Graph"),
             Details::Image(_) => Cow::from("Image"),
             Details::Heading(_) => Cow::from("Group"),
             Details::Message(diagnostic) => Cow::from(diagnostic.severity.as_title_str()),
@@ -329,7 +329,7 @@ impl Details {
 
     pub fn kind(&self) -> ItemKind {
         match self {
-            Details::Chart => ItemKind::Chart,
+            Details::Graph => ItemKind::Graph,
             Details::Image(_) => ItemKind::Image,
             Details::Heading(_) => ItemKind::Heading,
             Details::Message(_) => ItemKind::Message,
@@ -391,11 +391,11 @@ impl From<Box<Text>> for Details {
 }
 
 #[derive(Clone, Debug, Serialize)]
-pub struct Chart;
+pub struct Graph;
 
-impl Chart {
+impl Graph {
     pub fn into_item(self) -> Item {
-        Details::Chart.into_item()
+        Details::Graph.into_item()
     }
 }
 
@@ -619,18 +619,12 @@ impl SpvInfo {
         }
     }
 
-    pub fn with_members(self, members: SpvMembers) -> Self {
-        Self {
-            members: Some(members),
-            ..self
-        }
+    pub fn with_members(self, members: Option<SpvMembers>) -> Self {
+        Self { members, ..self }
     }
 
-    pub fn with_error(self) -> Self {
-        Self {
-            error: true,
-            ..self
-        }
+    pub fn with_error(self, error: bool) -> Self {
+        Self { error, ..self }
     }
 
     pub fn member_names(&self) -> Vec<&str> {
@@ -662,7 +656,7 @@ pub enum SpvMembers {
         /// `.png` file.
         String,
     ),
-    /// Chart members.
+    /// Graph members.
     Graph {
         /// Data member name.
         data: Option<String>,
@@ -693,7 +687,7 @@ impl SpvMembers {
 /// output item types; for example, "warnings" are a subset of text items.
 #[derive(Debug, EnumSetType)]
 pub enum Class {
-    Charts,
+    Graphs,
     Headings,
     Logs,
     Models,
@@ -713,7 +707,7 @@ impl FromStr for Class {
 
     fn from_str(s: &str) -> Result<Self, Self::Err> {
         match s {
-            "charts" => Ok(Self::Charts),
+            "graphs" | "charts" => Ok(Self::Graphs),
             "headings" => Ok(Self::Headings),
             "logs" => Ok(Self::Logs),
             "models" => Ok(Self::Models),
@@ -735,7 +729,7 @@ impl Item {
     fn class(&self) -> Class {
         let label = self.label.as_ref().map(|s| s.as_str());
         match &self.details {
-            Details::Chart => Class::Charts,
+            Details::Graph => Class::Graphs,
             Details::Image(_) => Class::Other,
             Details::Heading(_) => Class::OutlineHeaders,
             Details::Message(diagnostic) => match diagnostic.severity {
index b34214a6cf723b67874a68d754793e0d4a717e2a..1ed5e1b163974b1fa833102ad9721fede4860724 100644 (file)
@@ -360,7 +360,7 @@ impl Driver for CsvDriver {
     fn write(&mut self, item: &Arc<Item>) {
         // todo: error handling (should not unwrap)
         match &item.details {
-            Details::Chart | Details::Image(_) | Details::Heading(_) => (),
+            Details::Graph | Details::Image(_) | Details::Heading(_) => (),
             Details::Message(diagnostic) => {
                 self.start_item();
                 let text = diagnostic.to_string();
index 77b549963bf14f7f0d877db522d6c43cc1fa638f..ab50c1577835e5e3dc9ea5f39b418b18ad1a2724 100644 (file)
@@ -426,7 +426,7 @@ where
 
     fn write(&mut self, item: &Arc<Item>) {
         match &item.details {
-            Details::Chart | Details::Image(_) | Details::Heading(_) => todo!(),
+            Details::Graph | Details::Image(_) | Details::Heading(_) => todo!(),
             Details::Message(_diagnostic) => todo!(),
             Details::PageBreak => (),
             Details::Table(pivot_table) => {
index 5a11d6190c64dbb2a9e7f9fda8dc1188fd281007..af6a106cbb05c19a7b709bc40fce930506c30ddd 100644 (file)
@@ -403,7 +403,7 @@ impl TextRenderer {
         for item in ItemRefIterator::without_hidden(item).filter(|item| !item.details.is_heading())
         {
             match &item.details {
-                Details::Chart => {
+                Details::Graph => {
                     self.start_object(writer)?;
                     writeln!(writer, "Omitting chart from text output")?
                 }
index 3dfeb4d6693584f228e2386ce983d29c01df4744..10a7e5a22c685bec0b6edc0985f53d2849f8da5f 100644 (file)
@@ -18,32 +18,19 @@ use std::{
     cell::RefCell,
     fmt::Display,
     fs::File,
-    io::{BufReader, Cursor, Read, Seek},
+    io::{BufReader, Read, Seek},
     path::Path,
     rc::Rc,
 };
 
-use anyhow::Context;
-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::EncryptedReader,
-    output::{
-        Details, Item, SpvInfo, SpvMembers, Text,
-        page::{self, Orientation},
-        pivot::{Axis2, Length, TableProperties, look::Look, value::Value},
-    },
-    spv::read::{
-        html::Document,
-        legacy_bin::LegacyBin,
-        legacy_xml::Visualization,
-        light::{LightTable, LightWarning},
-    },
+    output::{Item, page::PageSetup},
+    spv::read::light::LightWarning,
 };
 
 mod css;
@@ -51,6 +38,7 @@ pub mod html;
 pub mod legacy_bin;
 mod legacy_xml;
 mod light;
+mod structure;
 #[cfg(test)]
 mod tests;
 
@@ -146,14 +134,14 @@ where
             ZipError::InvalidArchive(_) => Error::NotSpv,
             other => other.into(),
         })?;
-        Ok(self.open_zip_archive(archive)?)
+        Ok(self.open_archive(archive)?)
     }
 
     /// Opens the provided Zip `archive`.
     ///
     /// Any password provided for reading the file is unused, because if one was
     /// needed then it must have already been used to open the archive.
-    pub fn open_zip_archive(
+    pub fn open_archive(
         self,
         mut archive: ZipArchive<Box<dyn ReadSeek>>,
     ) -> Result<SpvFile, Error> {
@@ -175,9 +163,10 @@ where
         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(&mut archive, i, &name, &warn)?;
-                items.append(&mut new_items);
-                page_setup = page_setup.or(ps);
+                let member = BufReader::new(archive.by_index(i)?);
+                let member = structure::StructureMember::read(member, &name, &warn)?;
+                items.extend(member.root.read_items(&mut archive, &name, &warn));
+                page_setup = page_setup.or(member.page_setup);
             }
         }
 
@@ -195,7 +184,7 @@ pub struct SpvFile {
     pub items: Vec<Item>,
 
     /// The page setup in the SPV file, if any.
-    pub page_setup: Option<page::PageSetup>,
+    pub page_setup: Option<PageSetup>,
 
     /// The Zip archive that the file was read from.
     pub archive: ZipArchive<Box<dyn ReadSeek>>,
@@ -203,7 +192,7 @@ pub struct SpvFile {
 
 impl SpvFile {
     /// Returns the contents of the `SpvFile`.
-    pub fn into_contents(self) -> (Vec<Item>, Option<page::PageSetup>) {
+    pub fn into_contents(self) -> (Vec<Item>, Option<PageSetup>) {
         (self.items, self.page_setup)
     }
 
@@ -238,565 +227,23 @@ pub enum Error {
 
     /// {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,
-    warn: &Rc<RefCell<Box<dyn FnMut(Warning)>>>,
-) -> 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(warn, structure_member));
-    Ok((heading.decode(archive, structure_member, warn)?, 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,
-        warn: &Rc<RefCell<Box<dyn FnMut(Warning)>>>,
-    ) -> 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, warn)
-                            .unwrap_or_else(|error| {
-                                new_error_item(format!("Error reading table: {error}"))
-                                    .with_spv_info(SpvInfo::new(structure_member).with_error())
-                            }),
-                        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_or_else(|error| {
-                                new_error_item(format!("Error reading image: {error}"))
-                                    .with_spv_info(SpvInfo::new(structure_member).with_error())
-                            }),
-                        ContainerContent::Object(object) => object
-                            .decode(archive, structure_member)
-                            .unwrap_or_else(|error| {
-                                new_error_item(format!("Error reading object: {error}"))
-                                    .with_spv_info(SpvInfo::new(structure_member).with_error())
-                            }),
-                        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, warn)?
-                            .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<String>,
-    #[serde(rename = "@space-after")]
-    pub space_after: Option<Length>,
-    pub page_header: PageHeader,
-    pub page_footer: PageFooter,
-}
-
-impl PageSetup {
-    fn decode(
-        &self,
-        warn: &Rc<RefCell<Box<dyn FnMut(Warning)>>>,
-        structure_member: &str,
-    ) -> 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 {
-            if reference_orientation.starts_with("0") {
-                setup.orientation = Orientation::Portrait;
-            } else if reference_orientation.starts_with("90") {
-                setup.orientation = Orientation::Landscape;
-            } else {
-                (warn.borrow_mut())(Warning {
-                    member: structure_member.into(),
-                    details: WarningDetails::UnknownOrientation(reference_orientation.clone()),
-                });
-            }
-        }
-        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)]
-#[serde(rename_all = "kebab-case")]
-enum ChartSize {
-    FullHeight,
-    HalfHeight,
-    QuarterHeight,
-    #[default]
-    #[serde(other)]
-    AsIs,
-}
-
-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,
-        )
-    }
-}
+    /// Error parsing {member_name}: {error}
+    DeserializeError {
+        /// The name of the file inside the ZIP file that caused the error.
+        member_name: String,
+        /// Underlying error.
+        error: serde_path_to_error::Error<quick_xml::DeError>,
+    },
 
-#[derive(Deserialize, Debug)]
-#[serde(rename_all = "camelCase")]
-struct Object {
-    #[serde(rename = "@commandName")]
-    command_name: Option<String>,
-    #[serde(rename = "@uri")]
-    uri: String,
-}
+    /// Graphs not yet implemented.
+    GraphTodo,
 
-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)
-    }
-}
+    /// Models not yet implemented.
+    ModelTodo,
 
-#[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,
-    table_properties: Option<TableProperties>,
-    table_structure: TableStructure,
-}
-
-impl Table {
-    fn decode<R>(
-        &self,
-        archive: &mut ZipArchive<R>,
-        structure_member: &str,
-        warn: &Rc<RefCell<Box<dyn FnMut(Warning)>>>,
-    ) -> Result<Item, Error>
-    where
-        R: Read + Seek,
-    {
-        match &self.table_structure.path {
-            None => {
-                let member_name = self.table_structure.data_path.clone();
-                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 warn = warn.clone();
-                let warning = Rc::new(RefCell::new(Box::new({
-                    let warn = warn.clone();
-                    let member_name = member_name.clone();
-                    move |w| {
-                        (warn.borrow_mut())(Warning {
-                            member: member_name.clone(),
-                            details: WarningDetails::LightWarning(w),
-                        })
-                    }
-                })
-                    as Box<dyn FnMut(LightWarning)>));
-                let table =
-                    LightTable::read_args(&mut cursor, (warning.clone(),)).map_err(|e| {
-                        e.with_message(format!(
-                            "While parsing {member_name:?} as light binary SPV member"
-                        ))
-                    })?;
-                let pivot_table = table.decode(&mut *warning.borrow_mut());
-                Ok(pivot_table.into_item().with_spv_info(
-                    SpvInfo::new(structure_member).with_members(SpvMembers::LightTable(
-                        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.table_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::LegacyTable {
-                        xml: xml_member_name.clone(),
-                        binary: bin_member_name.clone(),
-                    }),
-                ))
-            }
-        }
-    }
+    /// Trees not yet implemented.
+    TreeTodo,
 }
 
 #[derive(Deserialize, Debug)]
@@ -806,41 +253,3 @@ enum TableType {
     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>,
-}
diff --git a/rust/pspp/src/spv/read/structure.rs b/rust/pspp/src/spv/read/structure.rs
new file mode 100644 (file)
index 0000000..e4f5239
--- /dev/null
@@ -0,0 +1,788 @@
+use std::{
+    cell::RefCell,
+    io::{BufRead, BufReader, Cursor, Read, Seek},
+    mem::take,
+    rc::Rc,
+};
+
+use anyhow::Context;
+use binrw::{BinRead, error::ContextExt};
+use cairo::ImageSurface;
+use zip::ZipArchive;
+
+use crate::{
+    output::{
+        Details, Item, SpvInfo, SpvMembers, Text,
+        page::PageSetup,
+        pivot::{
+            Length,
+            look::{HorzAlign, Look},
+        },
+    },
+    spv::{
+        Error,
+        legacy_bin::LegacyBin,
+        read::{
+            TableType, Warning, WarningDetails,
+            legacy_xml::Visualization,
+            light::{LightTable, LightWarning},
+        },
+    },
+};
+
+pub struct StructureMember {
+    pub page_setup: Option<PageSetup>,
+    pub root: Heading,
+}
+
+impl StructureMember {
+    pub fn read<R>(
+        reader: R,
+        member_name: &str,
+        warn: &Rc<RefCell<Box<dyn FnMut(Warning)>>>,
+    ) -> Result<Self, Error>
+    where
+        R: BufRead,
+    {
+        let mut heading: raw::Heading =
+            serde_path_to_error::deserialize(&mut quick_xml::de::Deserializer::from_reader(reader))
+                .map_err(|error| Error::DeserializeError {
+                    member_name: member_name.into(),
+                    error,
+                })?;
+        Ok(Self {
+            page_setup: heading
+                .page_setup
+                .take()
+                .map(|ps| ps.decode(warn, member_name)),
+            root: heading.decode(member_name, warn),
+        })
+    }
+}
+
+pub struct Heading {
+    page_setup: Option<PageSetup>,
+    expand: bool,
+    label: String,
+    children: Vec<HeadingChild>,
+    command_name: Option<String>,
+}
+
+impl Heading {
+    pub fn read_items<R>(
+        self,
+        archive: &mut ZipArchive<R>,
+        structure_member: &str,
+        warn: &Rc<RefCell<Box<dyn FnMut(Warning)>>>,
+    ) -> Vec<Item>
+    where
+        R: Read + Seek,
+    {
+        let mut items = Vec::new();
+        for child in self.children {
+            let mut spv_info = SpvInfo::new(structure_member).with_members(child.members());
+            let item = match child {
+                HeadingChild::Container(container) => {
+                    if container.page_break_before {
+                        items.push(
+                            Details::PageBreak
+                                .into_item()
+                                .with_spv_info(SpvInfo::new(structure_member)),
+                        );
+                    }
+                    let result = match container.content {
+                        Content::Table(table) => table.decode(archive, warn),
+                        Content::Graph(_) => Err(Error::GraphTodo),
+                        Content::Text(container_text) => Ok(container_text.into_item()),
+                        Content::Image(image) => image.decode(archive),
+                        Content::Model => Err(Error::ModelTodo),
+                        Content::Tree => Err(Error::TreeTodo),
+                    };
+                    spv_info.error = result.is_err();
+                    result
+                        .unwrap_or_else(|error| {
+                            Text::new_log(error.to_string())
+                                .into_item()
+                                .with_label("Error")
+                        })
+                        .with_show(container.show)
+                        .with_command_name(Some(container.command_name))
+                        .with_label(container.label)
+                }
+                HeadingChild::Heading(mut heading) => {
+                    let expand = heading.expand;
+                    let label = take(&mut heading.label);
+                    let command_name = take(&mut heading.command_name);
+                    heading
+                        .read_items(archive, structure_member, warn)
+                        .into_iter()
+                        .collect::<Item>()
+                        .with_show(expand)
+                        .with_label(label)
+                        .with_command_name(command_name)
+                        .with_spv_info(SpvInfo::new(structure_member))
+                }
+            };
+            items.push(item.with_spv_info(spv_info));
+        }
+        items
+    }
+}
+
+pub enum HeadingChild {
+    Heading(Heading),
+    Container(Container),
+}
+
+impl HeadingChild {
+    fn members(&self) -> Option<SpvMembers> {
+        match self {
+            HeadingChild::Heading(_) => None,
+            HeadingChild::Container(container) => container.content.members(),
+        }
+    }
+}
+
+pub struct Container {
+    show: bool,
+    page_break_before: bool,
+    text_align: Option<HorzAlign>,
+    command_name: String,
+    width: Option<Length>,
+    label: String,
+    content: Content,
+}
+
+pub enum Content {
+    Text(Text),
+    Table(Table),
+    Image(Image),
+    Graph(Graph),
+    Tree,
+    Model,
+}
+
+impl Content {
+    fn members(&self) -> Option<SpvMembers> {
+        match self {
+            Content::Text(_text) => None,
+            Content::Table(table) => Some(table.members()),
+            Content::Image(image) => Some(image.members()),
+            Content::Graph(graph) => Some(graph.members()),
+            Content::Tree => None,
+            Content::Model => None,
+        }
+    }
+}
+
+pub struct Table {
+    subtype: String,
+    table_type: TableType,
+    look: Option<Box<Look>>,
+    bin_member: String,
+    xml_member: Option<String>,
+}
+
+impl Table {
+    fn members(&self) -> SpvMembers {
+        if let Some(xml_member) = &self.xml_member {
+            SpvMembers::LegacyTable {
+                xml: xml_member.clone(),
+                binary: self.bin_member.clone(),
+            }
+        } else {
+            SpvMembers::LightTable(self.bin_member.clone())
+        }
+    }
+
+    fn decode<R>(
+        self,
+        archive: &mut ZipArchive<R>,
+        warn: &Rc<RefCell<Box<dyn FnMut(Warning)>>>,
+    ) -> Result<Item, Error>
+    where
+        R: Read + Seek,
+    {
+        if let Some(xml_member_name) = &self.xml_member {
+            let bin_member_name = &self.bin_member;
+            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.look.unwrap_or_default())?;
+
+            Ok(pivot_table.into_item())
+        } else {
+            let member_name = self.bin_member.clone();
+            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 warn = warn.clone();
+            let warning = Rc::new(RefCell::new(Box::new({
+                let warn = warn.clone();
+                let member_name = member_name.clone();
+                move |w| {
+                    (warn.borrow_mut())(Warning {
+                        member: member_name.clone(),
+                        details: WarningDetails::LightWarning(w),
+                    })
+                }
+            }) as Box<dyn FnMut(LightWarning)>));
+            let table = LightTable::read_args(&mut cursor, (warning.clone(),)).map_err(|e| {
+                e.with_message(format!(
+                    "While parsing {member_name:?} as light binary SPV member"
+                ))
+            })?;
+            let pivot_table = table.decode(&mut *warning.borrow_mut());
+            Ok(pivot_table.into_item())
+        }
+    }
+}
+
+pub struct Image {
+    member: String,
+}
+
+impl Image {
+    fn members(&self) -> SpvMembers {
+        SpvMembers::Image(self.member.clone())
+    }
+    fn decode<R>(self, archive: &mut ZipArchive<R>) -> Result<Item, Error>
+    where
+        R: Read + Seek,
+    {
+        let mut png = archive.by_name(&self.member)?;
+        let image = ImageSurface::create_from_png(&mut png)?;
+        Ok(Details::Image(image).into_item())
+    }
+}
+
+pub struct Graph {
+    xml_member: String,
+    data_member: Option<String>,
+    csv_member: Option<String>,
+}
+
+impl Graph {
+    fn members(&self) -> SpvMembers {
+        SpvMembers::Graph {
+            data: self.data_member.clone(),
+            xml: self.xml_member.clone(),
+            csv: self.csv_member.clone(),
+        }
+    }
+}
+
+mod raw {
+    use std::{cell::RefCell, rc::Rc};
+
+    use paper_sizes::PaperSize;
+    use serde::Deserialize;
+
+    use crate::{
+        output::{
+            Text,
+            page::{self, Orientation},
+            pivot::{
+                Axis2, Length, TableProperties,
+                look::{HorzAlign, Look},
+                value::Value,
+            },
+        },
+        spv::{
+            html::{self, Document},
+            read::{
+                TableType, Warning, WarningDetails,
+                structure::{Content, HeadingChild},
+            },
+        },
+    };
+
+    #[derive(Deserialize, Debug)]
+    #[serde(rename_all = "camelCase")]
+    pub struct Heading {
+        #[serde(rename = "@visibility")]
+        visibility: Option<String>,
+        #[serde(rename = "@commandName")]
+        command_name: Option<String>,
+        label: Label,
+        pub page_setup: Option<PageSetup>,
+
+        #[serde(rename = "$value")]
+        #[serde(default)]
+        children: Vec<HeadingContent>,
+    }
+
+    impl Heading {
+        pub fn decode(
+            self,
+            structure_member: &str,
+            warn: &Rc<RefCell<Box<dyn FnMut(Warning)>>>,
+        ) -> super::Heading {
+            let mut children = Vec::new();
+            for child in self.children {
+                match child {
+                    HeadingContent::Container(container) => {
+                        let (content, command_name) = match container.content {
+                            ContainerContent::Table(table) => (
+                                Content::Table(super::Table {
+                                    subtype: table.subtype,
+                                    table_type: table.table_type,
+                                    look: table.table_properties.map(|table_properties| {
+                                        Box::new(Look::from(*table_properties))
+                                    }),
+                                    bin_member: table.table_structure.bin_member,
+                                    xml_member: table.table_structure.xml_member,
+                                }),
+                                table.command_name,
+                            ),
+                            ContainerContent::Graph(graph) => (
+                                Content::Graph(super::Graph {
+                                    xml_member: graph.xml_member,
+                                    data_member: graph.data_member,
+                                    csv_member: graph.csv_member,
+                                }),
+                                graph.command_name,
+                            ),
+                            ContainerContent::Text(text) => (
+                                Content::Text(Text::new(
+                                    match text.text_type {
+                                        TextType::Title => crate::output::TextType::Title,
+                                        TextType::Log | TextType::Text => {
+                                            crate::output::TextType::Log
+                                        }
+                                        TextType::PageTitle => crate::output::TextType::PageTitle,
+                                    },
+                                    text.decode(),
+                                )),
+                                text.command_name.unwrap_or_default(),
+                            ),
+                            ContainerContent::Image(image) => (
+                                Content::Image(super::Image {
+                                    member: image.data_path,
+                                }),
+                                image.command_name.unwrap_or_default(),
+                            ),
+                            ContainerContent::Object(object) => (
+                                Content::Image(super::Image { member: object.uri }),
+                                object.command_name.unwrap_or_default(),
+                            ),
+                            ContainerContent::Model(model) => (Content::Model, model.command_name),
+                            ContainerContent::Tree(tree) => (Content::Tree, tree.command_name),
+                        };
+                        children.push(HeadingChild::Container(super::Container {
+                            show: container.visibility != Visibility::Hidden,
+                            page_break_before: container.page_break_before
+                                == PageBreakBefore::Always,
+                            text_align: container.text_align.map(HorzAlign::from),
+                            width: container.width,
+                            label: container.label.text,
+                            command_name,
+                            content,
+                        }));
+                    }
+                    HeadingContent::Heading(heading) => {
+                        children.push(HeadingChild::Heading(
+                            heading.decode(structure_member, warn),
+                        ));
+                    }
+                }
+            }
+            super::Heading {
+                page_setup: self
+                    .page_setup
+                    .map(|page_setup| page_setup.decode(warn, structure_member)),
+                expand: !self.visibility.is_some(),
+                label: self.label.text,
+                command_name: self.command_name,
+                children,
+            }
+        }
+    }
+
+    #[derive(Debug, Deserialize)]
+    #[serde(rename_all = "camelCase")]
+    pub 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<String>,
+        #[serde(rename = "@space-after")]
+        pub space_after: Option<Length>,
+        pub page_header: PageHeader,
+        pub page_footer: PageFooter,
+    }
+
+    impl PageSetup {
+        pub fn decode(
+            self,
+            warn: &Rc<RefCell<Box<dyn FnMut(Warning)>>>,
+            structure_member: &str,
+        ) -> 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 {
+                if reference_orientation.starts_with("0") {
+                    setup.orientation = Orientation::Portrait;
+                } else if reference_orientation.starts_with("90") {
+                    setup.orientation = Orientation::Landscape;
+                } else {
+                    (warn.borrow_mut())(Warning {
+                        member: structure_member.into(),
+                        details: WarningDetails::UnknownOrientation(reference_orientation.clone()),
+                    });
+                }
+            }
+            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")]
+    pub struct PageHeader {
+        page_paragraph: Option<PageParagraph>,
+    }
+
+    #[derive(Debug, Deserialize)]
+    #[serde(rename_all = "camelCase")]
+    pub 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)]
+    #[serde(rename_all = "kebab-case")]
+    pub enum ChartSize {
+        FullHeight,
+        HalfHeight,
+        QuarterHeight,
+        #[default]
+        #[serde(other)]
+        AsIs,
+    }
+
+    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<Length>,
+        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(Copy, Clone, Deserialize, Debug)]
+    #[serde(rename_all = "camelCase")]
+    enum TextAlign {
+        Left,
+        Center,
+        Right,
+    }
+
+    impl From<TextAlign> for HorzAlign {
+        fn from(value: TextAlign) -> Self {
+            match value {
+                TextAlign::Left => HorzAlign::Left,
+                TextAlign::Center => HorzAlign::Center,
+                TextAlign::Right => HorzAlign::Right,
+            }
+        }
+    }
+
+    #[derive(Deserialize, Debug)]
+    #[serde(rename_all = "camelCase")]
+    enum ContainerContent {
+        Table(Table),
+        Text(ContainerText),
+        Graph(Graph),
+        Model(Model),
+        Object(Object),
+        Image(Image),
+        Tree(Tree),
+    }
+
+    #[derive(Deserialize, Debug)]
+    #[serde(rename_all = "camelCase")]
+    struct Graph {
+        #[serde(rename = "@commandName")]
+        command_name: String,
+
+        /// Data.
+        #[serde(rename = "dataPath")]
+        data_member: Option<String>,
+
+        /// XML in VizML format.
+        #[serde(rename = "path")]
+        xml_member: String,
+
+        /// Rarely used, not understood.
+        #[serde(rename = "csvPath")]
+        csv_member: Option<String>,
+    }
+
+    #[derive(Deserialize, Debug)]
+    #[serde(rename_all = "camelCase")]
+    struct Model {
+        #[serde(rename = "@commandName")]
+        command_name: String,
+    }
+
+    #[derive(Deserialize, Debug)]
+    #[serde(rename_all = "camelCase")]
+    struct Tree {
+        #[serde(rename = "@commandName")]
+        command_name: String,
+    }
+
+    #[derive(Deserialize, Debug)]
+    #[serde(rename_all = "camelCase")]
+    struct Image {
+        #[serde(rename = "@commandName")]
+        command_name: Option<String>,
+        data_path: String,
+    }
+
+    #[derive(Deserialize, Debug)]
+    #[serde(rename_all = "camelCase")]
+    struct Object {
+        #[serde(rename = "@commandName")]
+        command_name: Option<String>,
+        #[serde(rename = "@uri")]
+        uri: String,
+    }
+
+    #[derive(Deserialize, Debug)]
+    #[serde(rename_all = "camelCase")]
+    struct Table {
+        #[serde(rename = "@commandName")]
+        command_name: String,
+        #[serde(rename = "@subType")]
+        subtype: String,
+        #[serde(rename = "@tableId")]
+        table_id: Option<i64>,
+        #[serde(rename = "@type")]
+        table_type: TableType,
+        table_properties: Option<Box<TableProperties>>,
+        table_structure: TableStructure,
+    }
+
+    #[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.
+        #[serde(rename = "path")]
+        xml_member: Option<String>,
+        /// The `.bin` member name.
+        #[serde(rename = "dataPath")]
+        bin_member: String,
+        /// Rarely used, not understood.
+        #[serde(rename = "csvPath")]
+        _csv_member: Option<String>,
+    }
+}
index e6f91e54223482c1875e1fb0a9e4c076151e1041..c0cd91050f77e780c99e81c830f9bd5a78936d57 100644 (file)
@@ -196,7 +196,7 @@ where
         X: Write,
     {
         match &item.details {
-            Details::Chart | Details::Image(_) => todo!(),
+            Details::Graph | Details::Image(_) => todo!(),
             Details::Heading(children) => {
                 let mut attributes = Vec::<Attribute>::new();
                 if let Some(command_name) = &item.command_name {