more tablelook
authorBen Pfaff <blp@cs.stanford.edu>
Sun, 9 Mar 2025 20:10:34 +0000 (13:10 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Sun, 9 Mar 2025 20:10:34 +0000 (13:10 -0700)
rust/pspp/src/output/pivot/look_xml.rs [new file with mode: 0644]
rust/pspp/src/output/pivot/mod.rs

diff --git a/rust/pspp/src/output/pivot/look_xml.rs b/rust/pspp/src/output/pivot/look_xml.rs
new file mode 100644 (file)
index 0000000..7175e03
--- /dev/null
@@ -0,0 +1,526 @@
+use std::{fmt::Debug, num::ParseFloatError, str::FromStr};
+
+use enum_map::enum_map;
+use serde::{de::Visitor, Deserialize};
+
+use crate::output::pivot::{
+    Area, AreaStyle, Axis2, Border, BorderStyle, BoxBorder, Color, FootnoteMarkerPosition,
+    FootnoteMarkerType, HeadingRegion, HorzAlign, Look, RowColBorder, RowLabelPosition, VertAlign,
+};
+use thiserror::Error as ThisError;
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+pub struct TableProperties {
+    #[serde(rename = "@name")]
+    name: Option<String>,
+    general_properties: GeneralProperties,
+    footnote_properties: FootnoteProperties,
+    cell_format_properties: CellFormatProperties,
+    border_properties: BorderProperties,
+    printing_properties: PrintingProperties,
+}
+
+impl From<TableProperties> for Look {
+    fn from(table_properties: TableProperties) -> Self {
+        Self {
+                name: table_properties.name,
+                omit_empty: table_properties.general_properties.hide_empty_rows,
+                row_label_position: table_properties.general_properties.row_label_position,
+                heading_widths: enum_map! {
+                    HeadingRegion::ColumnHeadings => table_properties.general_properties.minimum_column_width..=table_properties.general_properties.maximum_column_width,
+                    HeadingRegion::RowHeadings => table_properties.general_properties.minimum_row_width..=table_properties.general_properties.maximum_row_width,
+                }.map(|_k, r|(*r.start()).try_into().unwrap_or_default()..=(*r.end()).try_into().unwrap_or_default()),
+                footnote_marker_type: table_properties.footnote_properties.marker_type,
+                footnote_marker_position: table_properties.footnote_properties.marker_position,
+                areas: enum_map! {
+                    Area::Title => table_properties.cell_format_properties.title.style.as_area_style(),
+                    Area::Caption => table_properties.cell_format_properties.caption.style.as_area_style(),
+                    Area::Footer => table_properties.cell_format_properties.footnotes.style.as_area_style(),
+                    Area::Corner => table_properties.cell_format_properties.corner_labels.style.as_area_style(),
+                    Area::ColumnLabels => table_properties.cell_format_properties.column_labels.style.as_area_style(),
+                    Area::RowLabels => table_properties.cell_format_properties.row_labels.style.as_area_style(),
+                    Area::Data => table_properties.cell_format_properties.data.style.as_area_style(),
+                    Area::Layers => table_properties.cell_format_properties.layers.style.as_area_style(),
+                },
+                borders: enum_map!  {
+                    Border::Title => table_properties.border_properties.title_layer_separator,
+                    Border::OuterFrame(BoxBorder::Left) => table_properties.border_properties.left_outer_frame,
+                    Border::OuterFrame(BoxBorder::Top) => table_properties.border_properties.top_outer_frame,
+                    Border::OuterFrame(BoxBorder::Right) => table_properties.border_properties.right_outer_frame,
+                    Border::OuterFrame(BoxBorder::Bottom) => table_properties.border_properties.bottom_outer_frame,
+                    Border::InnerFrame(BoxBorder::Left) => table_properties.border_properties.left_inner_frame,
+                    Border::InnerFrame(BoxBorder::Top) => table_properties.border_properties.top_inner_frame,
+                    Border::InnerFrame(BoxBorder::Right) => table_properties.border_properties.right_inner_frame,
+                    Border::InnerFrame(BoxBorder::Bottom) => table_properties.border_properties.bottom_inner_frame,
+                    Border::Dimensions(RowColBorder::ColHorz) => table_properties.border_properties.horizontal_dimension_border_columns,
+                    Border::Dimensions(RowColBorder::ColVert) => table_properties.border_properties.vertical_dimension_border_columns,
+                    Border::Dimensions(RowColBorder::RowHorz) => table_properties.border_properties.horizontal_dimension_border_rows,
+                    Border::Dimensions(RowColBorder::RowVert) => table_properties.border_properties.vertical_dimension_border_rows,
+                    Border::Categories(RowColBorder::ColHorz) => table_properties.border_properties.horizontal_category_border_columns,
+                    Border::Categories(RowColBorder::ColVert) => table_properties.border_properties.vertical_category_border_columns,
+                    Border::Categories(RowColBorder::RowHorz) => table_properties.border_properties.horizontal_category_border_rows,
+                    Border::Categories(RowColBorder::RowVert) => table_properties.border_properties.vertical_category_border_rows,
+                    Border::DataLeft => table_properties.border_properties.data_area_left,
+                    Border::DataTop => table_properties.border_properties.data_area_top,
+                },
+                print_all_layers: table_properties.printing_properties.print_all_layers,
+                paginate_layers: table_properties
+                    .printing_properties
+                    .print_each_layer_on_separate_page,
+                shrink_to_fit: enum_map! {
+                    Axis2::X => table_properties.printing_properties.rescale_wide_table_to_fit_page,
+                    Axis2::Y => table_properties.printing_properties.rescale_long_table_to_fit_page,
+                },
+                top_continuation: table_properties
+                    .printing_properties
+                    .continuation_text_at_top,
+                bottom_continuation: table_properties
+                    .printing_properties
+                    .continuation_text_at_bottom,
+                continuation: {
+                    let text = table_properties.printing_properties.continuation_text;
+                    if text == "" {
+                        None
+                    } else {
+                        Some(text)
+                    }
+                },
+                n_orphan_lines: table_properties
+                    .printing_properties
+                    .window_orphan_lines
+                    .try_into()
+                    .unwrap_or_default(),
+            }
+    }
+}
+
+#[derive(Deserialize, Debug)]
+struct GeneralProperties {
+    #[serde(rename = "@hideEmptyRows")]
+    hide_empty_rows: bool,
+
+    #[serde(rename = "@maximumColumnWidth")]
+    maximum_column_width: i64,
+
+    #[serde(rename = "@minimumColumnWidth")]
+    minimum_column_width: i64,
+
+    #[serde(rename = "@maximumRowWidth")]
+    maximum_row_width: i64,
+
+    #[serde(rename = "@minimumRowWidth")]
+    minimum_row_width: i64,
+
+    #[serde(rename = "@rowDimensionLabels")]
+    row_label_position: RowLabelPosition,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct FootnoteProperties {
+    #[serde(rename = "@markerPosition")]
+    marker_position: FootnoteMarkerPosition,
+
+    #[serde(rename = "@numberFormat")]
+    marker_type: FootnoteMarkerType,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct CellFormatProperties {
+    caption: CellStyleHolder,
+    column_labels: CellStyleHolder,
+    corner_labels: CellStyleHolder,
+    data: CellStyleHolder,
+    footnotes: CellStyleHolder,
+    layers: CellStyleHolder,
+    row_labels: CellStyleHolder,
+    title: CellStyleHolder,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct CellStyleHolder {
+    style: CellStyle,
+}
+
+#[derive(Deserialize, Debug, Default)]
+#[serde(default)]
+struct CellStyle {
+    #[serde(rename = "@alternatingColor")]
+    alternating_color: Option<Color>,
+    #[serde(rename = "@alternatingTextColor")]
+    alternating_text_color: Option<Color>,
+    #[serde(rename = "@color")]
+    color: Option<Color>,
+    #[serde(rename = "@color2")]
+    color2: Option<Color>,
+    #[serde(rename = "@font-family")]
+    font_family: String,
+    #[serde(rename = "@font-size")]
+    font_size: Dimension,
+    #[serde(rename = "@font-style")]
+    font_style: FontStyle,
+    #[serde(rename = "@font-weight")]
+    font_weight: FontWeight,
+    #[serde(rename = "@font-underline")]
+    font_underline: FontUnderline,
+    #[serde(rename = "@labelLocationVertical")]
+    label_location_vertical: LabelLocationVertical,
+    #[serde(rename = "@margin-bottom")]
+    margin_bottom: Dimension,
+    #[serde(rename = "@margin-left")]
+    margin_left: Dimension,
+    #[serde(rename = "@margin-right")]
+    margin_right: Dimension,
+    #[serde(rename = "@margin-top")]
+    margin_top: Dimension,
+    #[serde(rename = "@textAlignment", default)]
+    text_alignment: TextAlignment,
+    #[serde(rename = "@decimal-offset")]
+    decimal_offset: Dimension,
+}
+
+impl CellStyle {
+    fn as_area_style(&self) -> AreaStyle {
+        AreaStyle {
+            cell_style: super::CellStyle {
+                horz_align: match self.text_alignment {
+                    TextAlignment::Left => Some(HorzAlign::Left),
+                    TextAlignment::Right => Some(HorzAlign::Right),
+                    TextAlignment::Center => Some(HorzAlign::Center),
+                    TextAlignment::Decimal => Some(HorzAlign::Decimal {
+                        offset: self.decimal_offset.as_px_f64(),
+                        c: '.',
+                    }),
+                    TextAlignment::Mixed => None,
+                },
+                vert_align: match self.label_location_vertical {
+                    LabelLocationVertical::Positive => VertAlign::Top,
+                    LabelLocationVertical::Negative => VertAlign::Bottom,
+                    LabelLocationVertical::Center => VertAlign::Middle,
+                },
+                margins: enum_map! {
+                    Axis2::X => [self.margin_left.as_px_i32(), self.margin_right.as_px_i32()],
+                    Axis2::Y => [self.margin_top.as_px_i32(), self.margin_bottom.as_px_i32()],
+                },
+            },
+            font_style: super::FontStyle {
+                bold: self.font_weight == FontWeight::Bold,
+                italic: self.font_style == FontStyle::Italic,
+                underline: self.font_underline == FontUnderline::Underline,
+                markup: false,
+                font: self.font_family.clone(),
+                fg: [
+                    self.color.unwrap_or(Color::BLACK),
+                    self.alternating_text_color.unwrap_or(Color::BLACK),
+                ],
+                bg: [
+                    self.color2.unwrap_or(Color::BLACK),
+                    self.alternating_color.unwrap_or(Color::BLACK),
+                ],
+                size: self.font_size.as_pt_i32(),
+            },
+        }
+    }
+}
+
+#[derive(Deserialize, Debug, Default, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+enum FontStyle {
+    #[default]
+    Regular,
+    Italic,
+}
+
+#[derive(Deserialize, Debug, Default, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+enum FontWeight {
+    #[default]
+    Regular,
+    Bold,
+}
+
+#[derive(Deserialize, Debug, Default, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+enum FontUnderline {
+    #[default]
+    None,
+    Underline,
+}
+
+#[derive(Deserialize, Debug, Default)]
+#[serde(rename_all = "camelCase")]
+enum TextAlignment {
+    Left,
+    Right,
+    Center,
+    Decimal,
+    #[default]
+    Mixed,
+}
+
+#[derive(Deserialize, Debug, Default)]
+#[serde(rename_all = "camelCase")]
+enum LabelLocationVertical {
+    /// Top.
+    #[default]
+    Positive,
+
+    /// Bottom.
+    Negative,
+
+    /// Center.
+    Center,
+}
+
+#[derive(Deserialize, Debug)]
+#[serde(rename_all = "camelCase")]
+struct BorderProperties {
+    bottom_inner_frame: BorderStyle,
+    bottom_outer_frame: BorderStyle,
+    data_area_left: BorderStyle,
+    data_area_top: BorderStyle,
+    horizontal_category_border_columns: BorderStyle,
+    horizontal_category_border_rows: BorderStyle,
+    horizontal_dimension_border_columns: BorderStyle,
+    horizontal_dimension_border_rows: BorderStyle,
+    left_inner_frame: BorderStyle,
+    left_outer_frame: BorderStyle,
+    right_inner_frame: BorderStyle,
+    right_outer_frame: BorderStyle,
+    title_layer_separator: BorderStyle,
+    top_inner_frame: BorderStyle,
+    top_outer_frame: BorderStyle,
+    vertical_category_border_columns: BorderStyle,
+    vertical_category_border_rows: BorderStyle,
+    vertical_dimension_border_rows: BorderStyle,
+    vertical_dimension_border_columns: BorderStyle,
+}
+
+#[derive(Deserialize, Debug, Default)]
+#[serde(rename_all = "camelCase", default)]
+struct PrintingProperties {
+    #[serde(rename = "@printAllLayers")]
+    print_all_layers: bool,
+
+    #[serde(rename = "@printEachLayerOnSeparatePage")]
+    print_each_layer_on_separate_page: bool,
+
+    #[serde(rename = "@rescaleWideTableToFitPage")]
+    rescale_wide_table_to_fit_page: bool,
+
+    #[serde(rename = "@rescaleLongTableToFitPage")]
+    rescale_long_table_to_fit_page: bool,
+
+    #[serde(rename = "@windowOrphanLines")]
+    window_orphan_lines: i64,
+
+    #[serde(rename = "@continuationText")]
+    continuation_text: String,
+
+    #[serde(rename = "@continuationTextAtBottom")]
+    continuation_text_at_bottom: bool,
+
+    #[serde(rename = "@continuationTextAtTop")]
+    continuation_text_at_top: bool,
+}
+
+#[derive(Copy, Clone, Default, PartialEq)]
+struct Dimension(
+    /// In inches.
+    f64,
+);
+
+impl Dimension {
+    fn as_px_f64(self) -> f64 {
+        self.0 * 96.0
+    }
+    fn as_px_i32(self) -> i32 {
+        num::cast(self.as_px_f64() + 0.5).unwrap_or_default()
+    }
+    fn as_pt_f64(self) -> f64 {
+        self.0 * 72.0
+    }
+    fn as_pt_i32(self) -> i32 {
+        num::cast(self.as_pt_f64() + 0.5).unwrap_or_default()
+    }
+}
+
+impl Debug for Dimension {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{:.2}in", self.0)
+    }
+}
+
+impl FromStr for Dimension {
+    type Err = DimensionParseError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let s = s.trim_start();
+        let unit = s.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.');
+        let number: f64 = s[..s.len() - unit.len()]
+            .parse()
+            .map_err(DimensionParseError::ParseFloatError)?;
+        let divisor = match unit.trim() {
+            // Inches.
+            "in" | "인치" | "pol." | "cala" | "cali" => 1.0,
+
+            // Device-independent pixels.
+            "px" => 96.0,
+
+            // Points.
+            "pt" | "пт" | "" => 72.0,
+
+            // Centimeters.
+            "cm" | "см" => 2.54,
+
+            other => return Err(DimensionParseError::InvalidUnit(other.into())),
+        };
+        Ok(Dimension(number / divisor))
+    }
+}
+
+#[derive(ThisError, Debug, PartialEq, Eq)]
+enum DimensionParseError {
+    /// Invalid number.
+    #[error("{0}")]
+    ParseFloatError(ParseFloatError),
+
+    /// Unknown unit.
+    #[error("Unknown unit {0:?}")]
+    InvalidUnit(String),
+}
+
+impl<'de> Deserialize<'de> for Dimension {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        struct DimensionVisitor;
+
+        impl<'de> Visitor<'de> for DimensionVisitor {
+            type Value = Dimension;
+
+            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+                formatter.write_str("a string")
+            }
+
+            fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
+            where
+                E: serde::de::Error,
+            {
+                v.parse().map_err(E::custom)
+            }
+        }
+
+        deserializer.deserialize_str(DimensionVisitor)
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use std::str::FromStr;
+
+    use quick_xml::de::from_str;
+
+    use crate::output::pivot::look_xml::{Dimension, DimensionParseError, TableProperties};
+
+    #[test]
+    fn dimension() {
+        assert_eq!(Dimension::from_str("1"), Ok(Dimension(1.0 / 72.0)));
+        assert_eq!(Dimension::from_str("1pt"), Ok(Dimension(1.0 / 72.0)));
+        assert_eq!(Dimension::from_str("1пт"), Ok(Dimension(1.0 / 72.0)));
+        assert_eq!(Dimension::from_str("1.0"), Ok(Dimension(1.0 / 72.0)));
+        assert_eq!(Dimension::from_str(" 1.0"), Ok(Dimension(1.0 / 72.0)));
+        assert_eq!(Dimension::from_str(" 1.0 "), Ok(Dimension(1.0 / 72.0)));
+        assert_eq!(Dimension::from_str("1.0 pt"), Ok(Dimension(1.0 / 72.0)));
+        assert_eq!(Dimension::from_str("1.0pt "), Ok(Dimension(1.0 / 72.0)));
+        assert_eq!(Dimension::from_str(" 1.0pt "), Ok(Dimension(1.0 / 72.0)));
+
+        assert_eq!(Dimension::from_str("1in"), Ok(Dimension(1.0)));
+
+        assert_eq!(Dimension::from_str("96px"), Ok(Dimension(1.0)));
+
+        assert_eq!(Dimension::from_str("2.54cm"), Ok(Dimension(1.0)));
+
+        assert_eq!(
+            Dimension::from_str(""),
+            Err(DimensionParseError::ParseFloatError(
+                "".parse::<f64>().unwrap_err()
+            ))
+        );
+        assert_eq!(
+            Dimension::from_str("1.2.3"),
+            Err(DimensionParseError::ParseFloatError(
+                "1.2.3".parse::<f64>().unwrap_err()
+            ))
+        );
+        assert_eq!(
+            Dimension::from_str("1asdf"),
+            Err(DimensionParseError::InvalidUnit("asdf".into()))
+        );
+    }
+
+    #[test]
+    fn look() {
+        const XML: &str = r##"
+<?xml version="1.0" encoding="UTF-8"?>
+<tableProperties xmlns="http://www.ibm.com/software/analytics/spss/xml/table-looks" xmlns:vizml="http://www.ibm.com/software/analytics/spss/xml/visualization" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.ibm.com/software/analytics/spss/xml/table-looks http://www.ibm.com/software/analytics/spss/xml/table-looks/table-looks-1.4.xsd">
+    <generalProperties hideEmptyRows="true" maximumColumnWidth="72" maximumRowWidth="120" minimumColumnWidth="36" minimumRowWidth="36" rowDimensionLabels="inCorner"/>
+    <footnoteProperties markerPosition="subscript" numberFormat="alphabetic"/>
+    <cellFormatProperties>
+        <title>
+            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="bold" font-underline="none" labelLocationVertical="center" margin-bottom="6pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="left"/>
+        </title>
+        <caption>
+            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="positive" margin-bottom="0pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="left"/>
+        </caption>
+        <footnotes>
+            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="positive" margin-bottom="2pt" margin-left="8pt" margin-right="6pt" margin-top="1pt" textAlignment="left"/>
+        </footnotes>
+        <cornerLabels>
+            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="negative" margin-bottom="0pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="left"/>
+        </cornerLabels>
+        <columnLabels>
+            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="negative" margin-bottom="2pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="center"/>
+        </columnLabels>
+        <rowLabels>
+            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="positive" margin-bottom="2pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="left"/>
+        </rowLabels>
+        <data>
+            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="positive" margin-bottom="0pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="mixed"/>
+        </data>
+        <layers>
+            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="negative" margin-bottom="2pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="left"/>
+        </layers>
+    </cellFormatProperties>
+    <borderProperties>
+        <titleLayerSeparator borderStyleType="none" color="#000000"/>
+        <leftOuterFrame borderStyleType="none" color="#000000"/>
+        <topOuterFrame borderStyleType="none" color="#000000"/>
+        <rightOuterFrame borderStyleType="none" color="#000000"/>
+        <bottomOuterFrame borderStyleType="none" color="#000000"/>
+        <leftInnerFrame borderStyleType="thick" color="#000000"/>
+        <topInnerFrame borderStyleType="thick" color="#000000"/>
+        <rightInnerFrame borderStyleType="thick" color="#000000"/>
+        <bottomInnerFrame borderStyleType="thick" color="#000000"/>
+        <dataAreaLeft borderStyleType="thick" color="#000000"/>
+        <dataAreaTop borderStyleType="thick" color="#000000"/>
+        <horizontalDimensionBorderRows borderStyleType="solid" color="#000000"/>
+        <verticalDimensionBorderRows borderStyleType="none" color="#000000"/>
+        <horizontalDimensionBorderColumns borderStyleType="solid" color="#000000"/>
+        <verticalDimensionBorderColumns borderStyleType="solid" color="#000000"/>
+        <horizontalCategoryBorderRows borderStyleType="none" color="#000000"/>
+        <verticalCategoryBorderRows borderStyleType="none" color="#000000"/>
+        <horizontalCategoryBorderColumns borderStyleType="solid" color="#000000"/>
+        <verticalCategoryBorderColumns borderStyleType="solid" color="#000000"/>
+    </borderProperties>
+    <printingProperties printAllLayers="true" rescaleLongTableToFitPage="false" rescaleWideTableToFitPage="false" windowOrphanLines="5"/>
+</tableProperties>
+"##;
+        let table_properties: TableProperties = from_str(XML).unwrap();
+        dbg!(&table_properties);
+    }
+}
index 6dcded30cd3885429cc7ce61c4c208dd1a4a4764..fd51041e20b8f9537b00e7bc4fd56b29be1da63c 100644 (file)
@@ -70,6 +70,7 @@ use color::{palette::css::TRANSPARENT, AlphaColor, Rgba8, Srgb};
 use encoding_rs::UTF_8;
 use enum_iterator::Sequence;
 use enum_map::{enum_map, Enum, EnumMap};
+use quick_xml::{de::from_str, DeError};
 use serde::{de::Visitor, Deserialize};
 use smallstr::SmallString;
 use smallvec::{smallvec, SmallVec};
@@ -83,6 +84,9 @@ use crate::{
 
 pub mod output;
 
+mod look_xml;
+pub use look_xml::TableProperties;
+
 /// Areas of a pivot table for styling purposes.
 #[derive(Copy, Clone, Debug, Default, Enum, PartialEq, Eq)]
 pub enum Area {
@@ -575,6 +579,10 @@ impl Look {
         static LOOK: OnceLock<Arc<Look>> = OnceLock::new();
         LOOK.get_or_init(|| Arc::new(Look::default())).clone()
     }
+
+    fn from_xml(xml: &str) -> Result<Self, DeError> {
+        Ok(from_str::<TableProperties>(xml)?.into())
+    }
 }
 
 #[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Eq)]
@@ -586,524 +594,6 @@ pub enum RowLabelPosition {
     InCorner,
 }
 
-mod look_xml {
-    use std::{fmt::Debug, num::ParseFloatError, str::FromStr};
-
-    use enum_map::enum_map;
-    use serde::{de::Visitor, Deserialize};
-
-    use crate::output::pivot::{
-        Area, AreaStyle, Axis2, Color, FootnoteMarkerPosition, FootnoteMarkerType, HeadingRegion,
-        HorzAlign, Look, RowLabelPosition, Stroke, VertAlign,
-    };
-    use thiserror::Error as ThisError;
-
-    #[derive(Deserialize, Debug)]
-    #[serde(rename_all = "camelCase")]
-    pub struct TableProperties {
-        #[serde(rename = "@name")]
-        name: Option<String>,
-        general_properties: GeneralProperties,
-        footnote_properties: FootnoteProperties,
-        cell_format_properties: CellFormatProperties,
-        border_properties: BorderProperties,
-        printing_properties: PrintingProperties,
-    }
-
-    impl From<TableProperties> for Look {
-        fn from(table_properties: TableProperties) -> Self {
-            Self {
-                name: table_properties.name,
-                omit_empty: table_properties.general_properties.hide_empty_rows,
-                row_label_position: table_properties.general_properties.row_label_position,
-                heading_widths: enum_map! {
-                    HeadingRegion::ColumnHeadings => table_properties.general_properties.minimum_column_width..=table_properties.general_properties.maximum_column_width,
-                    HeadingRegion::RowHeadings => table_properties.general_properties.minimum_row_width..=table_properties.general_properties.maximum_row_width,
-                }.map(|_k, r|(*r.start()).try_into().unwrap_or_default()..=(*r.end()).try_into().unwrap_or_default()),
-                footnote_marker_type: table_properties.footnote_properties.marker_type,
-                footnote_marker_position: table_properties.footnote_properties.marker_position,
-                areas: enum_map! {
-                    Area::Title => table_properties.cell_format_properties.title.style.as_area_style(),
-                    Area::Caption => table_properties.cell_format_properties.caption.style.as_area_style(),
-                    Area::Footer => table_properties.cell_format_properties.footnotes.style.as_area_style(),
-                    Area::Corner => table_properties.cell_format_properties.corner_labels.style.as_area_style(),
-                    Area::ColumnLabels => table_properties.cell_format_properties.column_labels.style.as_area_style(),
-                    Area::RowLabels => table_properties.cell_format_properties.row_labels.style.as_area_style(),
-                    Area::Data => table_properties.cell_format_properties.data.style.as_area_style(),
-                    Area::Layers => table_properties.cell_format_properties.layers.style.as_area_style(),
-                },
-                borders: todo!(),
-                print_all_layers: table_properties.printing_properties.print_all_layers,
-                paginate_layers: table_properties
-                    .printing_properties
-                    .print_each_layer_on_separate_page,
-                shrink_to_fit: enum_map! {
-                    Axis2::X => table_properties.printing_properties.rescale_wide_table_to_fit_page,
-                    Axis2::Y => table_properties.printing_properties.rescale_long_table_to_fit_page,
-                },
-                top_continuation: table_properties
-                    .printing_properties
-                    .continuation_text_at_top,
-                bottom_continuation: table_properties
-                    .printing_properties
-                    .continuation_text_at_bottom,
-                continuation: {
-                    let text = table_properties.printing_properties.continuation_text;
-                    if text == "" {
-                        None
-                    } else {
-                        Some(text)
-                    }
-                },
-                n_orphan_lines: table_properties
-                    .printing_properties
-                    .window_orphan_lines
-                    .try_into()
-                    .unwrap_or_default(),
-            }
-        }
-    }
-
-    #[derive(Deserialize, Debug)]
-    struct GeneralProperties {
-        #[serde(rename = "@hideEmptyRows")]
-        hide_empty_rows: bool,
-
-        #[serde(rename = "@maximumColumnWidth")]
-        maximum_column_width: i64,
-
-        #[serde(rename = "@minimumColumnWidth")]
-        minimum_column_width: i64,
-
-        #[serde(rename = "@maximumRowWidth")]
-        maximum_row_width: i64,
-
-        #[serde(rename = "@minimumRowWidth")]
-        minimum_row_width: i64,
-
-        #[serde(rename = "@rowDimensionLabels")]
-        row_label_position: RowLabelPosition,
-    }
-
-    #[derive(Deserialize, Debug)]
-    #[serde(rename_all = "camelCase")]
-    struct FootnoteProperties {
-        #[serde(rename = "@markerPosition")]
-        marker_position: FootnoteMarkerPosition,
-
-        #[serde(rename = "@numberFormat")]
-        marker_type: FootnoteMarkerType,
-    }
-
-    #[derive(Deserialize, Debug)]
-    #[serde(rename_all = "camelCase")]
-    struct CellFormatProperties {
-        caption: CellStyleHolder,
-        column_labels: CellStyleHolder,
-        corner_labels: CellStyleHolder,
-        data: CellStyleHolder,
-        footnotes: CellStyleHolder,
-        layers: CellStyleHolder,
-        row_labels: CellStyleHolder,
-        title: CellStyleHolder,
-    }
-
-    #[derive(Deserialize, Debug)]
-    #[serde(rename_all = "camelCase")]
-    struct CellStyleHolder {
-        style: CellStyle,
-    }
-
-    #[derive(Deserialize, Debug, Default)]
-    #[serde(default)]
-    struct CellStyle {
-        #[serde(rename = "@alternatingColor")]
-        alternating_color: Option<Color>,
-        #[serde(rename = "@alternatingTextColor")]
-        alternating_text_color: Option<Color>,
-        #[serde(rename = "@color")]
-        color: Option<Color>,
-        #[serde(rename = "@color2")]
-        color2: Option<Color>,
-        #[serde(rename = "@font-family")]
-        font_family: String,
-        #[serde(rename = "@font-size")]
-        font_size: Dimension,
-        #[serde(rename = "@font-style")]
-        font_style: FontStyle,
-        #[serde(rename = "@font-weight")]
-        font_weight: FontWeight,
-        #[serde(rename = "@font-underline")]
-        font_underline: FontUnderline,
-        #[serde(rename = "@labelLocationVertical")]
-        label_location_vertical: LabelLocationVertical,
-        #[serde(rename = "@margin-bottom")]
-        margin_bottom: Dimension,
-        #[serde(rename = "@margin-left")]
-        margin_left: Dimension,
-        #[serde(rename = "@margin-right")]
-        margin_right: Dimension,
-        #[serde(rename = "@margin-top")]
-        margin_top: Dimension,
-        #[serde(rename = "@textAlignment", default)]
-        text_alignment: TextAlignment,
-        #[serde(rename = "@decimal-offset")]
-        decimal_offset: Dimension,
-    }
-
-    impl CellStyle {
-        fn as_area_style(&self) -> AreaStyle {
-            AreaStyle {
-                cell_style: super::CellStyle {
-                    horz_align: match self.text_alignment {
-                        TextAlignment::Left => Some(HorzAlign::Left),
-                        TextAlignment::Right => Some(HorzAlign::Right),
-                        TextAlignment::Center => Some(HorzAlign::Center),
-                        TextAlignment::Decimal => Some(HorzAlign::Decimal {
-                            offset: self.decimal_offset.as_px_f64(),
-                            c: '.',
-                        }),
-                        TextAlignment::Mixed => None,
-                    },
-                    vert_align: match self.label_location_vertical {
-                        LabelLocationVertical::Positive => VertAlign::Top,
-                        LabelLocationVertical::Negative => VertAlign::Bottom,
-                        LabelLocationVertical::Center => VertAlign::Middle,
-                    },
-                    margins: enum_map! {
-                        Axis2::X => [self.margin_left.as_px_i32(), self.margin_right.as_px_i32()],
-                        Axis2::Y => [self.margin_top.as_px_i32(), self.margin_bottom.as_px_i32()],
-                    },
-                },
-                font_style: super::FontStyle {
-                    bold: self.font_weight == FontWeight::Bold,
-                    italic: self.font_style == FontStyle::Italic,
-                    underline: self.font_underline == FontUnderline::Underline,
-                    markup: false,
-                    font: self.font_family.clone(),
-                    fg: [
-                        self.color.unwrap_or(Color::BLACK),
-                        self.alternating_text_color.unwrap_or(Color::BLACK),
-                    ],
-                    bg: [
-                        self.color2.unwrap_or(Color::BLACK),
-                        self.alternating_color.unwrap_or(Color::BLACK),
-                    ],
-                    size: self.font_size.as_pt_i32(),
-                },
-            }
-        }
-    }
-
-    #[derive(Deserialize, Debug, Default, PartialEq, Eq)]
-    #[serde(rename_all = "camelCase")]
-    enum FontStyle {
-        #[default]
-        Regular,
-        Italic,
-    }
-
-    #[derive(Deserialize, Debug, Default, PartialEq, Eq)]
-    #[serde(rename_all = "camelCase")]
-    enum FontWeight {
-        #[default]
-        Regular,
-        Bold,
-    }
-
-    #[derive(Deserialize, Debug, Default, PartialEq, Eq)]
-    #[serde(rename_all = "camelCase")]
-    enum FontUnderline {
-        #[default]
-        None,
-        Underline,
-    }
-
-    #[derive(Deserialize, Debug, Default)]
-    #[serde(rename_all = "camelCase")]
-    enum TextAlignment {
-        Left,
-        Right,
-        Center,
-        Decimal,
-        #[default]
-        Mixed,
-    }
-
-    #[derive(Deserialize, Debug, Default)]
-    #[serde(rename_all = "camelCase")]
-    enum LabelLocationVertical {
-        /// Top.
-        #[default]
-        Positive,
-
-        /// Bottom.
-        Negative,
-
-        /// Center.
-        Center,
-    }
-
-    #[derive(Deserialize, Debug)]
-    #[serde(rename_all = "camelCase")]
-    struct BorderProperties {
-        bottom_inner_frame: BorderStyle,
-        bottom_outer_frame: BorderStyle,
-        data_area_left: BorderStyle,
-        data_area_top: BorderStyle,
-        horizontal_category_border_columns: BorderStyle,
-        horizontal_category_border_rows: BorderStyle,
-        horizontal_dimension_border_columns: BorderStyle,
-        horizontal_dimension_border_rows: BorderStyle,
-        left_inner_frame: BorderStyle,
-        left_outer_frame: BorderStyle,
-        right_inner_frame: BorderStyle,
-        right_outer_frame: BorderStyle,
-        title_layer_separator: BorderStyle,
-        top_inner_frame: BorderStyle,
-        top_outer_frame: BorderStyle,
-        vertical_category_border_columns: BorderStyle,
-        vertical_category_border_rows: BorderStyle,
-        vertical_dimension_border_rows: BorderStyle,
-        vertical_dimension_border_columns: BorderStyle,
-    }
-
-    #[derive(Deserialize, Debug)]
-    struct BorderStyle {
-        #[serde(rename = "@borderStyleType")]
-        border_style_type: Stroke,
-
-        #[serde(rename = "@color")]
-        color: Color,
-    }
-
-    #[derive(Deserialize, Debug, Default)]
-    #[serde(rename_all = "camelCase", default)]
-    struct PrintingProperties {
-        #[serde(rename = "@printAllLayers")]
-        print_all_layers: bool,
-
-        #[serde(rename = "@printEachLayerOnSeparatePage")]
-        print_each_layer_on_separate_page: bool,
-
-        #[serde(rename = "@rescaleWideTableToFitPage")]
-        rescale_wide_table_to_fit_page: bool,
-
-        #[serde(rename = "@rescaleLongTableToFitPage")]
-        rescale_long_table_to_fit_page: bool,
-
-        #[serde(rename = "@windowOrphanLines")]
-        window_orphan_lines: i64,
-
-        #[serde(rename = "@continuationText")]
-        continuation_text: String,
-
-        #[serde(rename = "@continuationTextAtBottom")]
-        continuation_text_at_bottom: bool,
-
-        #[serde(rename = "@continuationTextAtTop")]
-        continuation_text_at_top: bool,
-    }
-
-    #[derive(Copy, Clone, Default, PartialEq)]
-    struct Dimension(
-        /// In inches.
-        f64,
-    );
-
-    impl Dimension {
-        fn as_px_f64(self) -> f64 {
-            self.0 * 96.0
-        }
-        fn as_px_i32(self) -> i32 {
-            num::cast(self.as_px_f64() + 0.5).unwrap_or_default()
-        }
-        fn as_pt_f64(self) -> f64 {
-            self.0 * 72.0
-        }
-        fn as_pt_i32(self) -> i32 {
-            num::cast(self.as_pt_f64() + 0.5).unwrap_or_default()
-        }
-    }
-
-    impl Debug for Dimension {
-        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-            write!(f, "{:.2}in", self.0)
-        }
-    }
-
-    impl FromStr for Dimension {
-        type Err = DimensionParseError;
-
-        fn from_str(s: &str) -> Result<Self, Self::Err> {
-            let s = s.trim_start();
-            let unit = s.trim_start_matches(|c: char| c.is_ascii_digit() || c == '.');
-            let number: f64 = s[..s.len() - unit.len()]
-                .parse()
-                .map_err(DimensionParseError::ParseFloatError)?;
-            let divisor = match unit.trim() {
-                // Inches.
-                "in" | "인치" | "pol." | "cala" | "cali" => 1.0,
-
-                // Device-independent pixels.
-                "px" => 96.0,
-
-                // Points.
-                "pt" | "пт" | "" => 72.0,
-
-                // Centimeters.
-                "cm" | "см" => 2.54,
-
-                other => return Err(DimensionParseError::InvalidUnit(other.into())),
-            };
-            Ok(Dimension(number / divisor))
-        }
-    }
-
-    #[derive(ThisError, Debug, PartialEq, Eq)]
-    enum DimensionParseError {
-        /// Invalid number.
-        #[error("{0}")]
-        ParseFloatError(ParseFloatError),
-
-        /// Unknown unit.
-        #[error("Unknown unit {0:?}")]
-        InvalidUnit(String),
-    }
-
-    impl<'de> Deserialize<'de> for Dimension {
-        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-        where
-            D: serde::Deserializer<'de>,
-        {
-            struct DimensionVisitor;
-
-            impl<'de> Visitor<'de> for DimensionVisitor {
-                type Value = Dimension;
-
-                fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
-                    formatter.write_str("a string")
-                }
-
-                fn visit_borrowed_str<E>(self, v: &'de str) -> Result<Self::Value, E>
-                where
-                    E: serde::de::Error,
-                {
-                    v.parse().map_err(E::custom)
-                }
-            }
-
-            deserializer.deserialize_str(DimensionVisitor)
-        }
-    }
-
-    #[cfg(test)]
-    mod test {
-        use std::str::FromStr;
-
-        use quick_xml::de::from_str;
-
-        use crate::output::pivot::look_xml::{Dimension, DimensionParseError, TableProperties};
-
-        #[test]
-        fn dimension() {
-            assert_eq!(Dimension::from_str("1"), Ok(Dimension(1.0 / 72.0)));
-            assert_eq!(Dimension::from_str("1pt"), Ok(Dimension(1.0 / 72.0)));
-            assert_eq!(Dimension::from_str("1пт"), Ok(Dimension(1.0 / 72.0)));
-            assert_eq!(Dimension::from_str("1.0"), Ok(Dimension(1.0 / 72.0)));
-            assert_eq!(Dimension::from_str(" 1.0"), Ok(Dimension(1.0 / 72.0)));
-            assert_eq!(Dimension::from_str(" 1.0 "), Ok(Dimension(1.0 / 72.0)));
-            assert_eq!(Dimension::from_str("1.0 pt"), Ok(Dimension(1.0 / 72.0)));
-            assert_eq!(Dimension::from_str("1.0pt "), Ok(Dimension(1.0 / 72.0)));
-            assert_eq!(Dimension::from_str(" 1.0pt "), Ok(Dimension(1.0 / 72.0)));
-
-            assert_eq!(Dimension::from_str("1in"), Ok(Dimension(1.0)));
-
-            assert_eq!(Dimension::from_str("96px"), Ok(Dimension(1.0)));
-
-            assert_eq!(Dimension::from_str("2.54cm"), Ok(Dimension(1.0)));
-
-            assert_eq!(
-                Dimension::from_str(""),
-                Err(DimensionParseError::ParseFloatError(
-                    "".parse::<f64>().unwrap_err()
-                ))
-            );
-            assert_eq!(
-                Dimension::from_str("1.2.3"),
-                Err(DimensionParseError::ParseFloatError(
-                    "1.2.3".parse::<f64>().unwrap_err()
-                ))
-            );
-            assert_eq!(
-                Dimension::from_str("1asdf"),
-                Err(DimensionParseError::InvalidUnit("asdf".into()))
-            );
-        }
-
-        #[test]
-        fn look() {
-            const XML: &str = r##"
-<?xml version="1.0" encoding="UTF-8"?>
-<tableProperties xmlns="http://www.ibm.com/software/analytics/spss/xml/table-looks" xmlns:vizml="http://www.ibm.com/software/analytics/spss/xml/visualization" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.ibm.com/software/analytics/spss/xml/table-looks http://www.ibm.com/software/analytics/spss/xml/table-looks/table-looks-1.4.xsd">
-    <generalProperties hideEmptyRows="true" maximumColumnWidth="72" maximumRowWidth="120" minimumColumnWidth="36" minimumRowWidth="36" rowDimensionLabels="inCorner"/>
-    <footnoteProperties markerPosition="subscript" numberFormat="alphabetic"/>
-    <cellFormatProperties>
-        <title>
-            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="bold" font-underline="none" labelLocationVertical="center" margin-bottom="6pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="left"/>
-        </title>
-        <caption>
-            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="positive" margin-bottom="0pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="left"/>
-        </caption>
-        <footnotes>
-            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="positive" margin-bottom="2pt" margin-left="8pt" margin-right="6pt" margin-top="1pt" textAlignment="left"/>
-        </footnotes>
-        <cornerLabels>
-            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="negative" margin-bottom="0pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="left"/>
-        </cornerLabels>
-        <columnLabels>
-            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="negative" margin-bottom="2pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="center"/>
-        </columnLabels>
-        <rowLabels>
-            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="positive" margin-bottom="2pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="left"/>
-        </rowLabels>
-        <data>
-            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="positive" margin-bottom="0pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="mixed"/>
-        </data>
-        <layers>
-            <vizml:style color="#000000" color2="#ffffff" font-family="Sans Serif" font-size="9pt" font-weight="regular" font-underline="none" labelLocationVertical="negative" margin-bottom="2pt" margin-left="6pt" margin-right="8pt" margin-top="0pt" textAlignment="left"/>
-        </layers>
-    </cellFormatProperties>
-    <borderProperties>
-        <titleLayerSeparator borderStyleType="none" color="#000000"/>
-        <leftOuterFrame borderStyleType="none" color="#000000"/>
-        <topOuterFrame borderStyleType="none" color="#000000"/>
-        <rightOuterFrame borderStyleType="none" color="#000000"/>
-        <bottomOuterFrame borderStyleType="none" color="#000000"/>
-        <leftInnerFrame borderStyleType="thick" color="#000000"/>
-        <topInnerFrame borderStyleType="thick" color="#000000"/>
-        <rightInnerFrame borderStyleType="thick" color="#000000"/>
-        <bottomInnerFrame borderStyleType="thick" color="#000000"/>
-        <dataAreaLeft borderStyleType="thick" color="#000000"/>
-        <dataAreaTop borderStyleType="thick" color="#000000"/>
-        <horizontalDimensionBorderRows borderStyleType="solid" color="#000000"/>
-        <verticalDimensionBorderRows borderStyleType="none" color="#000000"/>
-        <horizontalDimensionBorderColumns borderStyleType="solid" color="#000000"/>
-        <verticalDimensionBorderColumns borderStyleType="solid" color="#000000"/>
-        <horizontalCategoryBorderRows borderStyleType="none" color="#000000"/>
-        <verticalCategoryBorderRows borderStyleType="none" color="#000000"/>
-        <horizontalCategoryBorderColumns borderStyleType="solid" color="#000000"/>
-        <verticalCategoryBorderColumns borderStyleType="solid" color="#000000"/>
-    </borderProperties>
-    <printingProperties printAllLayers="true" rescaleLongTableToFitPage="false" rescaleWideTableToFitPage="false" windowOrphanLines="5"/>
-</tableProperties>
-"##;
-            let table_properties: TableProperties = from_str(XML).unwrap();
-            dbg!(&table_properties);
-        }
-    }
-}
-
 /// The heading region of a rendered pivot table:
 ///
 /// ```text
@@ -1311,9 +801,12 @@ impl<'de> Deserialize<'de> for Color {
     }
 }
 
-#[derive(Copy, Clone, Debug)]
+#[derive(Copy, Clone, Debug, Deserialize)]
 pub struct BorderStyle {
+    #[serde(rename = "@borderStyleType")]
     pub stroke: Stroke,
+
+    #[serde(rename = "@color")]
     pub color: Color,
 }