add new pivot::look module.
authorBen Pfaff <blp@cs.stanford.edu>
Thu, 11 Dec 2025 17:28:47 +0000 (09:28 -0800)
committerBen Pfaff <blp@cs.stanford.edu>
Thu, 11 Dec 2025 17:28:47 +0000 (09:28 -0800)
24 files changed:
rust/pspp/src/output.rs
rust/pspp/src/output/drivers/cairo.rs
rust/pspp/src/output/drivers/cairo/driver.rs
rust/pspp/src/output/drivers/cairo/fsm.rs
rust/pspp/src/output/drivers/cairo/pager.rs
rust/pspp/src/output/drivers/html.rs
rust/pspp/src/output/drivers/text.rs
rust/pspp/src/output/drivers/text/text_line.rs
rust/pspp/src/output/pivot.rs
rust/pspp/src/output/pivot/look.rs [new file with mode: 0644]
rust/pspp/src/output/pivot/look_xml.rs
rust/pspp/src/output/pivot/output.rs
rust/pspp/src/output/pivot/tests.rs
rust/pspp/src/output/pivot/tlo.rs
rust/pspp/src/output/pivot/value.rs
rust/pspp/src/output/render.rs
rust/pspp/src/output/table.rs
rust/pspp/src/settings.rs
rust/pspp/src/spv/read.rs
rust/pspp/src/spv/read/css.rs
rust/pspp/src/spv/read/html.rs
rust/pspp/src/spv/read/legacy_xml.rs
rust/pspp/src/spv/read/light.rs
rust/pspp/src/spv/write.rs

index 225051e6e2b508209bd67e404cabfb1f42e10c7c..106616679d432aba0fa9f52c3a77de40b8e0a238 100644 (file)
@@ -37,7 +37,10 @@ use serde::Serialize;
 
 use crate::{
     message::{Diagnostic, Severity},
-    output::pivot::{Axis3, BorderStyle, Dimension, Group, Look},
+    output::pivot::{
+        Axis3, Dimension, Group,
+        look::{BorderStyle, Look},
+    },
 };
 
 use self::pivot::Value;
index b1b34d5f1355c63c5b6ad52f1b5c4caeb5b525d7..f64fe1bf37c17c845e87cf63044e4609af290581 100644 (file)
@@ -16,7 +16,7 @@
 
 use pango::SCALE;
 
-use crate::output::pivot::HorzAlign;
+use crate::output::pivot::look::HorzAlign;
 
 mod driver;
 pub mod fsm;
index 7a4933ae851f433e0b2ed51b19f990d7680694a0..bbe75c40b353ab01492487c6fccfa0bcf0e5725c 100644 (file)
@@ -38,7 +38,10 @@ use crate::{
             },
         },
         page::PageSetup,
-        pivot::{Color, Coord2, FontStyle},
+        pivot::{
+            Coord2,
+            look::{Color, FontStyle},
+        },
     },
     spv::html::Variable,
 };
index 4f38fabc3f50cc8183399760bab952837efe9302..277666cca0971e4ddb4aae2ab0a2935be585ecc4 100644 (file)
@@ -26,13 +26,19 @@ use pango::{
 use pangocairo::functions::show_layout;
 use smallvec::{SmallVec, smallvec};
 
-use crate::output::drivers::cairo::{px_to_xr, xr_to_pt};
-use crate::output::pivot::{Axis2, BorderStyle, Coord2, FontStyle, HorzAlign, Rect2, Stroke};
-use crate::output::render::{Device, Extreme, Pager, Params};
-use crate::output::table::DrawCell;
-use crate::output::{Details, Item};
-use crate::output::{pivot::Color, table::Content};
-use crate::spv::html::Markup;
+use crate::{
+    output::{
+        Details, Item,
+        drivers::cairo::{px_to_xr, xr_to_pt},
+        pivot::{
+            Axis2, Coord2, Rect2,
+            look::{BorderStyle, Color, FontStyle, HorzAlign, Stroke},
+        },
+        render::{Device, Extreme, Pager, Params},
+        table::{Content, DrawCell},
+    },
+    spv::html::Markup,
+};
 
 /// Width of an ordinary line.
 const LINE_WIDTH: isize = LINE_SPACE / 2;
index 62f999d2b6d284242f4007aa9817207c82223ab6..5ae677e19d8af4af6d086880ef0812853b37466d 100644 (file)
@@ -27,7 +27,11 @@ use crate::{
             fsm::{CairoFsm, CairoFsmStyle},
             xr_to_pt,
         },
-        pivot::{Axis2, CellStyle, FontStyle, Rect2, value::ValueOptions},
+        pivot::{
+            Axis2, Rect2,
+            look::{CellStyle, FontStyle},
+            value::ValueOptions,
+        },
         table::DrawCell,
     },
     spv::html::{Document, Variable},
index ad19aded6c89ac1ab30fb9fe47f9463f11d287f8..6552ebe8ae490fd6b76d26449081df8e9bc5d526 100644 (file)
@@ -29,7 +29,10 @@ use smallstr::SmallString;
 use crate::output::{
     Details, Item,
     drivers::Driver,
-    pivot::{Axis2, BorderStyle, Color, HorzAlign, PivotTable, Stroke, VertAlign},
+    pivot::{
+        Axis2, PivotTable,
+        look::{BorderStyle, Color, HorzAlign, Stroke, VertAlign},
+    },
     table::{CellPos, CellRect, DrawCell, Table},
 };
 
index 423664dd029871abdf3f03fe32626a64f8412221..333a9029086a537b2a8e371324dbb3f8a4148a5e 100644 (file)
@@ -34,7 +34,10 @@ use crate::output::{ItemRefIterator, render::Extreme, table::DrawCell};
 use crate::output::{
     Details, Item,
     drivers::Driver,
-    pivot::{Axis2, BorderStyle, Coord2, HorzAlign, PivotTable, Rect2, Stroke},
+    pivot::{
+        Axis2, Coord2, PivotTable, Rect2,
+        look::{BorderStyle, HorzAlign, Stroke},
+    },
     render::{Device, Pager, Params},
     table::Content,
 };
index b986163011d504d38ae912fb4e57d56b915c2058..bd31c5f932201a19d180727b87ab32c013106f1c 100644 (file)
@@ -24,7 +24,7 @@ use std::{
 
 use unicode_width::UnicodeWidthChar;
 
-use crate::output::pivot::FontStyle;
+use crate::output::pivot::look::FontStyle;
 
 /// A line of text, encoded in UTF-8, with support functions that properly
 /// handle double-width characters and backspaces.
index 1944ef3a260f0130a0577bd9ef74049f77d05afc..f418636b176212ae1e04b8a835f9909511b23076 100644 (file)
 use std::{
     collections::HashMap,
     fmt::{Debug, Display},
-    io::Read,
     iter::{FusedIterator, once, repeat_n},
-    ops::{Index, IndexMut, Not, Range, RangeInclusive},
-    str::{FromStr, Utf8Error, from_utf8},
-    sync::{Arc, OnceLock},
+    ops::{Index, IndexMut, Not, Range},
+    sync::Arc,
 };
 
-use binrw::Error as BinError;
 use chrono::NaiveDateTime;
-pub use color::ParseError as ParseColorError;
-use color::{AlphaColor, Rgba8, Srgb, palette::css::TRANSPARENT};
 use enum_iterator::Sequence;
 use enum_map::{Enum, EnumMap, enum_map};
 use itertools::Itertools;
 pub use look_xml::{Length, TableProperties};
-use quick_xml::{DeError, de::from_str};
-use serde::{
-    Deserialize, Serialize,
-    de::Visitor,
-    ser::{SerializeMap, SerializeStruct},
-};
+use serde::{Deserialize, Serialize, ser::SerializeMap};
 use smallvec::SmallVec;
-use thiserror::Error as ThisError;
-use tlo::parse_tlo;
 
 use crate::{
-    format::{Decimal, F40, F40_2, F40_3, Format, PCT40_1, Settings as FormatSettings},
-    output::pivot::value::{
+    format::{Format, Settings as FormatSettings, F40, F40_2, F40_3, PCT40_1},
+    output::pivot::{look::{Look, Sizing}, value::{
         BareValue, DisplayValue, IntoValueOptions, NumberValue, ValueInner, ValueOptions,
-    },
+    }},
     settings::{Settings, Show},
-    util::ToSmallString,
-    variable::{VarType, Variable},
+    variable::Variable,
 };
 pub(crate) use tlo::parse_bool;
 
@@ -91,230 +78,7 @@ mod tlo;
 #[cfg(test)]
 pub mod tests;
 
-/// Areas of a pivot table for styling purposes.
-#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)]
-pub enum Area {
-    /// Title.
-    Title,
-
-    /// Caption.
-    Caption,
-
-    /// Footnotes,
-    Footer,
-
-    // Top-left corner.
-    Corner,
-
-    /// Labels.
-    Labels(
-        /// - [Axis2::X]: Column labels, along the top of the table.
-        /// - [Axis2::Y]: Row labels, along the left side of the table.
-        Axis2,
-    ),
-
-    /// Data cells.
-    Data(
-        /// This allows styling for even rows and odd rows to differ
-        /// arbitrarily, but the SPV file format only distinguishes foreground
-        /// and background colors, so any other differences will be lost upon
-        /// save.
-        RowParity,
-    ),
-
-    /// Layer indication.
-    Layers,
-}
-
-impl Default for Area {
-    fn default() -> Self {
-        Self::Data(RowParity::default())
-    }
-}
-
-impl Display for Area {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Area::Title => write!(f, "title"),
-            Area::Caption => write!(f, "caption"),
-            Area::Footer => write!(f, "footer"),
-            Area::Corner => write!(f, "corner"),
-            Area::Labels(axis2) => write!(f, "labels({axis2})"),
-            Area::Data(row) => write!(f, "data({row})"),
-            Area::Layers => write!(f, "layers"),
-        }
-    }
-}
-
-impl Serialize for Area {
-    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-    where
-        S: serde::Serializer,
-    {
-        serializer.serialize_str(&self.to_small_string::<16>())
-    }
-}
-
-/// Distinguishes [Area::Data] for even-numbered and odd-numbered rows.
-#[derive(Copy, Clone, Debug, Default, Enum, PartialEq, Eq)]
-pub enum RowParity {
-    /// Even-numbered rows.
-    ///
-    /// The first row is row 0, hence even.
-    #[default]
-    Even,
-    /// Odd-numbered rows.
-    Odd,
-}
-
-impl From<usize> for RowParity {
-    fn from(value: usize) -> Self {
-        if value % 2 == 1 {
-            Self::Odd
-        } else {
-            Self::Even
-        }
-    }
-}
-
-impl Display for RowParity {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            RowParity::Even => write!(f, "even"),
-            RowParity::Odd => write!(f, "odd"),
-        }
-    }
-}
-
-/// Table borders for styling purposes.
-#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)]
-pub enum Border {
-    Title,
-    OuterFrame(BoxBorder),
-    InnerFrame(BoxBorder),
-    Dimension(RowColBorder),
-    Category(RowColBorder),
-    DataLeft,
-    DataTop,
-}
-
-impl Border {
-    pub fn default_stroke(self) -> Stroke {
-        match self {
-            Self::InnerFrame(_) | Self::DataLeft | Self::DataTop => Stroke::Thick,
-            Self::Dimension(
-                RowColBorder(HeadingRegion::Columns, _) | RowColBorder(_, Axis2::X),
-            )
-            | Self::Category(RowColBorder(HeadingRegion::Columns, _)) => Stroke::Solid,
-            _ => Stroke::None,
-        }
-    }
-    pub fn default_border_style(self) -> BorderStyle {
-        BorderStyle {
-            stroke: self.default_stroke(),
-            color: Color::BLACK,
-        }
-    }
-
-    fn fallback(self) -> Self {
-        match self {
-            Self::Title
-            | Self::OuterFrame(_)
-            | Self::InnerFrame(_)
-            | Self::DataLeft
-            | Self::DataTop
-            | Self::Category(_) => self,
-            Self::Dimension(row_col_border) => Self::Category(row_col_border),
-        }
-    }
-
-    pub fn default_borders() -> EnumMap<Border, BorderStyle> {
-        EnumMap::from_fn(Border::default_border_style)
-    }
-}
-
-impl Display for Border {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        match self {
-            Border::Title => write!(f, "title"),
-            Border::OuterFrame(box_border) => write!(f, "outer_frame({box_border})"),
-            Border::InnerFrame(box_border) => write!(f, "inner_frame({box_border})"),
-            Border::Dimension(row_col_border) => write!(f, "dimension({row_col_border})"),
-            Border::Category(row_col_border) => write!(f, "category({row_col_border})"),
-            Border::DataLeft => write!(f, "data(left)"),
-            Border::DataTop => write!(f, "data(top)"),
-        }
-    }
-}
-
-impl Serialize for Border {
-    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-    where
-        S: serde::Serializer,
-    {
-        serializer.serialize_str(&self.to_small_string::<32>())
-    }
-}
-
-/// The borders on a box.
-#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Serialize)]
-#[serde(rename_all = "snake_case")]
-pub enum BoxBorder {
-    Left,
-    Top,
-    Right,
-    Bottom,
-}
-
-impl BoxBorder {
-    fn as_str(&self) -> &'static str {
-        match self {
-            BoxBorder::Left => "left",
-            BoxBorder::Top => "top",
-            BoxBorder::Right => "right",
-            BoxBorder::Bottom => "bottom",
-        }
-    }
-}
-
-impl Display for BoxBorder {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        f.write_str(self.as_str())
-    }
-}
-
-/// Borders between rows and columns.
-#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Serialize)]
-#[serde(rename_all = "snake_case")]
-pub struct RowColBorder(
-    /// Row or column headings.
-    pub HeadingRegion,
-    /// Horizontal ([Axis2::X]) or vertical ([Axis2::Y]) borders.
-    pub Axis2,
-);
-
-impl Display for RowColBorder {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "{}:{}", self.0, self.1)
-    }
-}
-
-/// Sizing for rows or columns of a rendered table.
-///
-/// The comments below talk about columns and their widths but they apply
-/// equally to rows and their heights.
-#[derive(Default, Clone, Debug, Serialize)]
-pub struct Sizing {
-    /// Specific column widths, in 1/96" units.
-    pub widths: Vec<i32>,
-
-    /// Specific page breaks: 0-based columns after which a page break must
-    /// occur, e.g. a value of 1 requests a break after the second column.
-    pub breaks: Vec<usize>,
-
-    /// Keeps: columns to keep together on a page if possible.
-    pub keeps: Vec<Range<usize>>,
-}
+pub mod look;
 
 /// A 3-dimensional axis.
 #[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Sequence, Serialize)]
@@ -939,626 +703,6 @@ impl From<&String> for Category {
     }
 }
 
-/// Styling for a pivot table.
-///
-/// The division between this and the style information in [PivotTable] seems
-/// fairly arbitrary.  The ultimate reason for the division is simply because
-/// that's how SPSS documentation and file formats do it.
-#[derive(Clone, Debug, PartialEq, Serialize)]
-pub struct Look {
-    pub name: Option<String>,
-
-    /// Whether to hide rows or columns whose cells are all empty.
-    pub hide_empty: bool,
-
-    pub row_label_position: LabelPosition,
-
-    /// Ranges of column widths in the two heading regions, in 1/96" units.
-    pub heading_widths: EnumMap<HeadingRegion, RangeInclusive<isize>>,
-
-    /// Kind of markers to use for footnotes.
-    pub footnote_marker_type: FootnoteMarkerType,
-
-    /// Where to put the footnote markers.
-    pub footnote_marker_position: FootnoteMarkerPosition,
-
-    /// Styles for areas of the pivot table.
-    pub areas: EnumMap<Area, AreaStyle>,
-
-    /// Styles for borders in the pivot table.
-    pub borders: EnumMap<Border, BorderStyle>,
-
-    pub print_all_layers: bool,
-
-    pub paginate_layers: bool,
-
-    pub shrink_to_fit: EnumMap<Axis2, bool>,
-
-    pub top_continuation: bool,
-
-    pub bottom_continuation: bool,
-
-    pub continuation: Option<String>,
-
-    pub n_orphan_lines: usize,
-}
-
-impl Look {
-    pub fn with_omit_empty(mut self, omit_empty: bool) -> Self {
-        self.hide_empty = omit_empty;
-        self
-    }
-    pub fn with_row_label_position(mut self, row_label_position: LabelPosition) -> Self {
-        self.row_label_position = row_label_position;
-        self
-    }
-    pub fn with_borders(mut self, borders: EnumMap<Border, BorderStyle>) -> Self {
-        self.borders = borders;
-        self
-    }
-}
-
-impl Default for Look {
-    fn default() -> Self {
-        Self {
-            name: None,
-            hide_empty: true,
-            row_label_position: LabelPosition::default(),
-            heading_widths: EnumMap::from_fn(|region| match region {
-                HeadingRegion::Rows => 36..=72,
-                HeadingRegion::Columns => 36..=120,
-            }),
-            footnote_marker_type: FootnoteMarkerType::default(),
-            footnote_marker_position: FootnoteMarkerPosition::default(),
-            areas: EnumMap::from_fn(AreaStyle::default_for_area),
-            borders: Border::default_borders(),
-            print_all_layers: false,
-            paginate_layers: false,
-            shrink_to_fit: EnumMap::from_fn(|_| false),
-            top_continuation: false,
-            bottom_continuation: false,
-            continuation: None,
-            n_orphan_lines: 0,
-        }
-    }
-}
-
-#[derive(ThisError, Debug)]
-pub enum ParseLookError {
-    #[error(transparent)]
-    XmlError(#[from] DeError),
-
-    #[error(transparent)]
-    Utf8Error(#[from] Utf8Error),
-
-    #[error(transparent)]
-    BinError(#[from] BinError),
-
-    #[error(transparent)]
-    IoError(#[from] std::io::Error),
-}
-
-impl Look {
-    pub fn shared_default() -> Arc<Look> {
-        static LOOK: OnceLock<Arc<Look>> = OnceLock::new();
-        LOOK.get_or_init(|| Arc::new(Look::default())).clone()
-    }
-
-    pub fn from_xml(xml: &str) -> Result<Self, ParseLookError> {
-        Ok(from_str::<TableProperties>(xml)
-            .map_err(ParseLookError::from)?
-            .into())
-    }
-
-    pub fn from_binary(tlo: &[u8]) -> Result<Self, ParseLookError> {
-        parse_tlo(tlo).map_err(ParseLookError::from)
-    }
-
-    pub fn from_data(data: &[u8]) -> Result<Self, ParseLookError> {
-        if data.starts_with(b"\xff\xff\0\0") {
-            Self::from_binary(data)
-        } else {
-            Self::from_xml(from_utf8(data).map_err(ParseLookError::from)?)
-        }
-    }
-
-    pub fn from_reader<R>(mut reader: R) -> Result<Self, ParseLookError>
-    where
-        R: Read,
-    {
-        let mut buffer = Vec::new();
-        reader
-            .read_to_end(&mut buffer)
-            .map_err(ParseLookError::from)?;
-        Self::from_data(&buffer)
-    }
-}
-
-/// Position for group labels.
-#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
-pub enum LabelPosition {
-    /// Hierarchically enclosing the categories.
-    ///
-    /// For column labels, group labels appear above the categories.  For row
-    /// labels, group labels appear to the left of the categories.
-    ///
-    /// ```text
-    /// ┌────┬──────────────┐   ┌─────────┬──────────┐
-    /// │    │    nested    │   │         │ columns  │
-    /// │    ├────┬────┬────┤   ├──────┬──┼──────────┤
-    /// │    │ a1 │ a2 │ a3 │   │      │a1│...data...│
-    /// ├────┼────┼────┼────┤   │nested│a2│...data...│
-    /// │    │data│data│data│   │      │a3│...data...│
-    /// │    │ .  │ .  │ .  │   └──────┴──┴──────────┘
-    /// │rows│ .  │ .  │ .  │
-    /// │    │ .  │ .  │ .  │
-    /// └────┴────┴────┴────┘
-    /// ```
-    #[serde(rename = "nested")]
-    Nested,
-
-    /// In the corner (row labels only).
-    ///
-    /// ```text
-    /// ┌──────┬──────────┐
-    /// │corner│ columns  │
-    /// ├──────┼──────────┤
-    /// │    a1│...data...│
-    /// │    a2│...data...│
-    /// │    a3│...data...│
-    /// └──────┴──────────┘
-    /// ```
-    #[default]
-    #[serde(rename = "inCorner")]
-    Corner,
-}
-
-/// The heading region of a rendered pivot table:
-///
-/// ```text
-/// ┌──────────────────┬─────────────────────────────────────────────────┐
-/// │                  │                  column headings                │
-/// │                  ├─────────────────────────────────────────────────┤
-/// │      corner      │                                                 │
-/// │       and        │                                                 │
-/// │   row headings   │                      data                       │
-/// │                  │                                                 │
-/// │                  │                                                 │
-/// └──────────────────┴─────────────────────────────────────────────────┘
-/// ```
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Enum, Serialize)]
-#[serde(rename_all = "snake_case")]
-pub enum HeadingRegion {
-    Rows,
-    Columns,
-}
-
-impl HeadingRegion {
-    pub fn as_str(&self) -> &'static str {
-        match self {
-            HeadingRegion::Rows => "rows",
-            HeadingRegion::Columns => "columns",
-        }
-    }
-}
-
-impl Display for HeadingRegion {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "{}", self.as_str())
-    }
-}
-
-impl From<Axis2> for HeadingRegion {
-    fn from(axis: Axis2) -> Self {
-        match axis {
-            Axis2::X => HeadingRegion::Columns,
-            Axis2::Y => HeadingRegion::Rows,
-        }
-    }
-}
-
-#[derive(Clone, Debug, PartialEq, Serialize)]
-pub struct AreaStyle {
-    pub cell_style: CellStyle,
-    pub font_style: FontStyle,
-}
-
-impl AreaStyle {
-    pub fn default_for_area(area: Area) -> Self {
-        Self {
-            cell_style: CellStyle::default_for_area(area),
-            font_style: FontStyle::default_for_area(area),
-        }
-    }
-}
-
-#[derive(Clone, Debug, Serialize, PartialEq)]
-pub struct CellStyle {
-    /// `None` means "mixed" alignment: align strings to the left, numbers to
-    /// the right.
-    pub horz_align: Option<HorzAlign>,
-    pub vert_align: VertAlign,
-
-    /// Margins in 1/96" units.
-    ///
-    /// `margins[Axis2::X][0]` is the left margin.
-    /// `margins[Axis2::X][1]` is the right margin.
-    /// `margins[Axis2::Y][0]` is the top margin.
-    /// `margins[Axis2::Y][1]` is the bottom margin.
-    pub margins: EnumMap<Axis2, [i32; 2]>,
-}
-
-impl Default for CellStyle {
-    fn default() -> Self {
-        Self::default_for_area(Area::default())
-    }
-}
-
-impl CellStyle {
-    pub fn default_for_area(area: Area) -> Self {
-        use HorzAlign::*;
-        use VertAlign::*;
-        let (horz_align, vert_align, hmargins, vmargins) = match area {
-            Area::Title => (Some(Center), Middle, [8, 11], [1, 8]),
-            Area::Caption => (Some(Left), Top, [8, 11], [1, 1]),
-            Area::Footer => (Some(Left), Top, [11, 8], [2, 3]),
-            Area::Corner => (Some(Left), Bottom, [8, 11], [1, 1]),
-            Area::Labels(Axis2::X) => (Some(Center), Bottom, [8, 11], [1, 3]),
-            Area::Labels(Axis2::Y) => (Some(Left), Top, [8, 11], [1, 3]),
-            Area::Data(_) => (None, Top, [8, 11], [1, 1]),
-            Area::Layers => (Some(Left), Bottom, [8, 11], [1, 3]),
-        };
-        Self {
-            horz_align,
-            vert_align,
-            margins: enum_map! { Axis2::X => hmargins, Axis2::Y => vmargins },
-        }
-    }
-    pub fn with_horz_align(self, horz_align: Option<HorzAlign>) -> Self {
-        Self { horz_align, ..self }
-    }
-    pub fn with_vert_align(self, vert_align: VertAlign) -> Self {
-        Self { vert_align, ..self }
-    }
-    pub fn with_margins(self, margins: EnumMap<Axis2, [i32; 2]>) -> Self {
-        Self { margins, ..self }
-    }
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Deserialize, Serialize)]
-#[serde(rename_all = "snake_case")]
-pub enum HorzAlign {
-    /// Right aligned.
-    Right,
-
-    /// Left aligned.
-    Left,
-
-    /// Centered.
-    Center,
-
-    /// Align the decimal point at the specified position.
-    Decimal {
-        /// Decimal offset from the right side of the cell, in 1/96" units.
-        offset: f64,
-
-        /// Decimal character.
-        decimal: Decimal,
-    },
-}
-
-impl HorzAlign {
-    pub fn for_mixed(var_type: VarType) -> Self {
-        match var_type {
-            VarType::Numeric => Self::Right,
-            VarType::String => Self::Left,
-        }
-    }
-
-    pub fn as_str(&self) -> Option<&'static str> {
-        match self {
-            HorzAlign::Right => Some("right"),
-            HorzAlign::Left => Some("left"),
-            HorzAlign::Center => Some("center"),
-            HorzAlign::Decimal { .. } => None,
-        }
-    }
-}
-
-/// Unknown horizontal alignment.
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
-pub struct UnknownHorzAlign;
-
-impl FromStr for HorzAlign {
-    type Err = UnknownHorzAlign;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        if s.eq_ignore_ascii_case("left") {
-            Ok(Self::Left)
-        } else if s.eq_ignore_ascii_case("center") {
-            Ok(Self::Center)
-        } else if s.eq_ignore_ascii_case("right") {
-            Ok(Self::Right)
-        } else {
-            Err(UnknownHorzAlign)
-        }
-    }
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
-#[serde(rename_all = "snake_case")]
-pub enum VertAlign {
-    /// Top alignment.
-    Top,
-
-    /// Centered,
-    Middle,
-
-    /// Bottom alignment.
-    Bottom,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
-pub struct FontStyle {
-    pub bold: bool,
-    pub italic: bool,
-    pub underline: bool,
-    pub font: String,
-    pub fg: Color,
-    pub bg: Color,
-
-    /// In 1/72" units.
-    pub size: i32,
-}
-
-impl Default for FontStyle {
-    fn default() -> Self {
-        FontStyle {
-            bold: false,
-            italic: false,
-            underline: false,
-            font: String::from("Sans Serif"),
-            fg: Color::BLACK,
-            bg: Color::WHITE,
-            size: 9,
-        }
-    }
-}
-
-impl FontStyle {
-    pub fn default_for_area(area: Area) -> Self {
-        Self::default().with_bold(area == Area::Title)
-    }
-    pub fn with_size(self, size: i32) -> Self {
-        Self { size, ..self }
-    }
-    pub fn with_bold(self, bold: bool) -> Self {
-        Self { bold, ..self }
-    }
-    pub fn with_italic(self, italic: bool) -> Self {
-        Self { italic, ..self }
-    }
-    pub fn with_underline(self, underline: bool) -> Self {
-        Self { underline, ..self }
-    }
-    pub fn with_font(self, font: impl Into<String>) -> Self {
-        Self {
-            font: font.into(),
-            ..self
-        }
-    }
-    pub fn with_fg(self, fg: Color) -> Self {
-        Self { fg, ..self }
-    }
-    pub fn with_bg(self, fg: Color) -> Self {
-        Self { fg, ..self }
-    }
-}
-
-#[derive(Copy, Clone, PartialEq, Eq)]
-pub struct Color {
-    pub alpha: u8,
-    pub r: u8,
-    pub g: u8,
-    pub b: u8,
-}
-
-impl Color {
-    pub const BLACK: Color = Color::new(0, 0, 0);
-    pub const WHITE: Color = Color::new(255, 255, 255);
-    pub const RED: Color = Color::new(255, 0, 0);
-    pub const BLUE: Color = Color::new(0, 0, 255);
-    pub const TRANSPARENT: Color = Color::new(0, 0, 0).with_alpha(0);
-
-    pub const fn new(r: u8, g: u8, b: u8) -> Self {
-        Self {
-            alpha: 255,
-            r,
-            g,
-            b,
-        }
-    }
-
-    pub const fn with_alpha(self, alpha: u8) -> Self {
-        Self { alpha, ..self }
-    }
-
-    pub const fn without_alpha(self) -> Self {
-        self.with_alpha(255)
-    }
-
-    /// Displays opaque colors as `#rrggbb` and others as `rgb(r, g, b, alpha)`.
-    pub fn display_css(&self) -> DisplayCss {
-        DisplayCss(*self)
-    }
-
-    pub fn into_rgb(&self) -> (u8, u8, u8) {
-        (self.r, self.g, self.b)
-    }
-
-    pub fn into_rgb16(&self) -> (u16, u16, u16) {
-        (
-            self.r as u16 * 257,
-            self.g as u16 * 257,
-            self.b as u16 * 257,
-        )
-    }
-}
-
-impl Debug for Color {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "{}", self.display_css())
-    }
-}
-
-impl From<Rgba8> for Color {
-    fn from(Rgba8 { r, g, b, a }: Rgba8) -> Self {
-        Self::new(r, g, b).with_alpha(a)
-    }
-}
-
-impl FromStr for Color {
-    type Err = ParseColorError;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        fn is_bare_hex(s: &str) -> bool {
-            let s = s.trim();
-            s.chars().count() == 6 && s.chars().all(|c| c.is_ascii_hexdigit())
-        }
-        let color: AlphaColor<Srgb> = match s.parse() {
-            Err(_) if is_bare_hex(s) => ("#".to_owned() + s).parse(),
-            Err(_) if s.trim().eq_ignore_ascii_case("transparent") => Ok(TRANSPARENT),
-            other => other,
-        }?;
-        Ok(color.to_rgba8().into())
-    }
-}
-
-impl Serialize for Color {
-    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-    where
-        S: serde::Serializer,
-    {
-        serializer.serialize_str(&self.display_css().to_small_string::<32>())
-    }
-}
-
-impl<'de> Deserialize<'de> for Color {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: serde::Deserializer<'de>,
-    {
-        struct ColorVisitor;
-
-        impl<'de> Visitor<'de> for ColorVisitor {
-            type Value = Color;
-
-            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
-                formatter.write_str("\"#rrggbb\" or \"rrggbb\" or web color name")
-            }
-
-            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
-            where
-                E: serde::de::Error,
-            {
-                v.parse().map_err(E::custom)
-            }
-        }
-
-        deserializer.deserialize_str(ColorVisitor)
-    }
-}
-
-pub struct DisplayCss(Color);
-
-impl Display for DisplayCss {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        let Color { alpha, r, g, b } = self.0;
-        match alpha {
-            255 => write!(f, "#{r:02x}{g:02x}{b:02x}"),
-            _ => write!(f, "rgb({r}, {g}, {b}, {:.2})", alpha as f64 / 255.0),
-        }
-    }
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Deserialize)]
-pub struct BorderStyle {
-    #[serde(rename = "@borderStyleType")]
-    pub stroke: Stroke,
-
-    #[serde(rename = "@color")]
-    pub color: Color,
-}
-
-impl Serialize for BorderStyle {
-    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-    where
-        S: serde::Serializer,
-    {
-        let mut s = serializer.serialize_struct("BorderStyle", 2)?;
-        s.serialize_field("stroke", &self.stroke)?;
-        s.serialize_field("color", &self.color)?;
-        s.end()
-    }
-}
-
-impl From<Stroke> for BorderStyle {
-    fn from(value: Stroke) -> Self {
-        Self::new(value)
-    }
-}
-
-impl BorderStyle {
-    pub const fn new(stroke: Stroke) -> Self {
-        Self {
-            stroke,
-            color: Color::BLACK,
-        }
-    }
-
-    pub const fn none() -> Self {
-        Self::new(Stroke::None)
-    }
-
-    pub fn is_none(&self) -> bool {
-        self.stroke.is_none()
-    }
-
-    /// Returns a border style that "combines" the two arguments, that is, that
-    /// gives a reasonable choice for a rule for different reasons should have
-    /// both styles.
-    pub fn combine(self, other: BorderStyle) -> Self {
-        Self {
-            stroke: self.stroke.combine(other.stroke),
-            color: self.color,
-        }
-    }
-}
-
-#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Enum, Deserialize, Serialize)]
-#[serde(rename_all = "camelCase")]
-pub enum Stroke {
-    None,
-    Solid,
-    Dashed,
-    Thick,
-    Thin,
-    Double,
-}
-
-impl Stroke {
-    pub fn is_none(&self) -> bool {
-        self == &Self::None
-    }
-
-    /// Returns a stroke that "combines" the two arguments, that is, that gives
-    /// a reasonable stroke choice for a rule for different reasons should have
-    /// both styles.
-    pub fn combine(self, other: Stroke) -> Self {
-        self.max(other)
-    }
-}
-
 /// An axis of a 2-dimensional table.
 #[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "snake_case")]
@@ -2331,7 +1475,7 @@ impl Display for Display26Adic {
             number /= 26;
         }
         output.reverse();
-        write!(f, "{}", from_utf8(&output).unwrap())
+        write!(f, "{}", str::from_utf8(&output).unwrap())
     }
 }
 
@@ -2431,7 +1575,8 @@ mod test {
     use std::str::FromStr;
 
     use crate::output::pivot::{
-        Color, Display26Adic, MetadataEntry, MetadataValue, Value,
+        Display26Adic, MetadataEntry, MetadataValue, Value,
+        look::Color,
         tests::assert_rendering,
         value::{TemplateValue, ValueInner},
     };
diff --git a/rust/pspp/src/output/pivot/look.rs b/rust/pspp/src/output/pivot/look.rs
new file mode 100644 (file)
index 0000000..583eb41
--- /dev/null
@@ -0,0 +1,866 @@
+use std::{
+    fmt::{Debug, Display},
+    io::Read,
+    ops::{Range, RangeInclusive},
+    str::{FromStr, Utf8Error},
+    sync::{Arc, OnceLock},
+};
+
+use color::{AlphaColor, Rgba8, Srgb, palette::css::TRANSPARENT};
+use enum_map::{Enum, EnumMap, enum_map};
+use quick_xml::{DeError, de::from_str};
+use serde::{Deserialize, Serialize, de::Visitor, ser::SerializeStruct};
+
+use crate::{
+    format::Decimal,
+    output::pivot::{
+        Axis2, FootnoteMarkerPosition, FootnoteMarkerType, TableProperties, tlo::parse_tlo,
+    },
+    util::ToSmallString,
+    variable::VarType,
+};
+
+/// Areas of a pivot table for styling purposes.
+#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)]
+pub enum Area {
+    /// Title.
+    Title,
+
+    /// Caption.
+    Caption,
+
+    /// Footnotes,
+    Footer,
+
+    // Top-left corner.
+    Corner,
+
+    /// Labels.
+    Labels(
+        /// - [Axis2::X]: Column labels, along the top of the table.
+        /// - [Axis2::Y]: Row labels, along the left side of the table.
+        Axis2,
+    ),
+
+    /// Data cells.
+    Data(
+        /// This allows styling for even rows and odd rows to differ
+        /// arbitrarily, but the SPV file format only distinguishes foreground
+        /// and background colors, so any other differences will be lost upon
+        /// save.
+        RowParity,
+    ),
+
+    /// Layer indication.
+    Layers,
+}
+
+impl Default for Area {
+    fn default() -> Self {
+        Self::Data(RowParity::default())
+    }
+}
+
+impl Display for Area {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Area::Title => write!(f, "title"),
+            Area::Caption => write!(f, "caption"),
+            Area::Footer => write!(f, "footer"),
+            Area::Corner => write!(f, "corner"),
+            Area::Labels(axis2) => write!(f, "labels({axis2})"),
+            Area::Data(row) => write!(f, "data({row})"),
+            Area::Layers => write!(f, "layers"),
+        }
+    }
+}
+
+impl Serialize for Area {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_str(&self.to_small_string::<16>())
+    }
+}
+
+/// Distinguishes [Area::Data] for even-numbered and odd-numbered rows.
+#[derive(Copy, Clone, Debug, Default, Enum, PartialEq, Eq)]
+pub enum RowParity {
+    /// Even-numbered rows.
+    ///
+    /// The first row is row 0, hence even.
+    #[default]
+    Even,
+    /// Odd-numbered rows.
+    Odd,
+}
+
+impl From<usize> for RowParity {
+    fn from(value: usize) -> Self {
+        if value % 2 == 1 {
+            Self::Odd
+        } else {
+            Self::Even
+        }
+    }
+}
+
+impl Display for RowParity {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            RowParity::Even => write!(f, "even"),
+            RowParity::Odd => write!(f, "odd"),
+        }
+    }
+}
+
+/// Table borders for styling purposes.
+#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)]
+pub enum Border {
+    Title,
+    OuterFrame(BoxBorder),
+    InnerFrame(BoxBorder),
+    Dimension(RowColBorder),
+    Category(RowColBorder),
+    DataLeft,
+    DataTop,
+}
+
+impl Border {
+    pub fn default_stroke(self) -> Stroke {
+        match self {
+            Self::InnerFrame(_) | Self::DataLeft | Self::DataTop => Stroke::Thick,
+            Self::Dimension(
+                RowColBorder(HeadingRegion::Columns, _) | RowColBorder(_, Axis2::X),
+            )
+            | Self::Category(RowColBorder(HeadingRegion::Columns, _)) => Stroke::Solid,
+            _ => Stroke::None,
+        }
+    }
+    pub fn default_border_style(self) -> BorderStyle {
+        BorderStyle {
+            stroke: self.default_stroke(),
+            color: Color::BLACK,
+        }
+    }
+
+    pub fn fallback(self) -> Self {
+        match self {
+            Self::Title
+            | Self::OuterFrame(_)
+            | Self::InnerFrame(_)
+            | Self::DataLeft
+            | Self::DataTop
+            | Self::Category(_) => self,
+            Self::Dimension(row_col_border) => Self::Category(row_col_border),
+        }
+    }
+
+    pub fn default_borders() -> EnumMap<Border, BorderStyle> {
+        EnumMap::from_fn(Border::default_border_style)
+    }
+}
+
+impl Display for Border {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Border::Title => write!(f, "title"),
+            Border::OuterFrame(box_border) => write!(f, "outer_frame({box_border})"),
+            Border::InnerFrame(box_border) => write!(f, "inner_frame({box_border})"),
+            Border::Dimension(row_col_border) => write!(f, "dimension({row_col_border})"),
+            Border::Category(row_col_border) => write!(f, "category({row_col_border})"),
+            Border::DataLeft => write!(f, "data(left)"),
+            Border::DataTop => write!(f, "data(top)"),
+        }
+    }
+}
+
+impl Serialize for Border {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_str(&self.to_small_string::<32>())
+    }
+}
+
+/// The borders on a box.
+#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum BoxBorder {
+    Left,
+    Top,
+    Right,
+    Bottom,
+}
+
+impl BoxBorder {
+    fn as_str(&self) -> &'static str {
+        match self {
+            BoxBorder::Left => "left",
+            BoxBorder::Top => "top",
+            BoxBorder::Right => "right",
+            BoxBorder::Bottom => "bottom",
+        }
+    }
+}
+
+impl Display for BoxBorder {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(self.as_str())
+    }
+}
+
+/// Borders between rows and columns.
+#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub struct RowColBorder(
+    /// Row or column headings.
+    pub HeadingRegion,
+    /// Horizontal ([Axis2::X]) or vertical ([Axis2::Y]) borders.
+    pub Axis2,
+);
+
+impl Display for RowColBorder {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}:{}", self.0, self.1)
+    }
+}
+
+/// Sizing for rows or columns of a rendered table.
+///
+/// The comments below talk about columns and their widths but they apply
+/// equally to rows and their heights.
+#[derive(Default, Clone, Debug, Serialize)]
+pub struct Sizing {
+    /// Specific column widths, in 1/96" units.
+    pub widths: Vec<i32>,
+
+    /// Specific page breaks: 0-based columns after which a page break must
+    /// occur, e.g. a value of 1 requests a break after the second column.
+    pub breaks: Vec<usize>,
+
+    /// Keeps: columns to keep together on a page if possible.
+    pub keeps: Vec<Range<usize>>,
+}
+
+/// Styling for a pivot table.
+///
+/// The division between this and the style information in [PivotTable] seems
+/// fairly arbitrary.  The ultimate reason for the division is simply because
+/// that's how SPSS documentation and file formats do it.
+#[derive(Clone, Debug, PartialEq, Serialize)]
+pub struct Look {
+    pub name: Option<String>,
+
+    /// Whether to hide rows or columns whose cells are all empty.
+    pub hide_empty: bool,
+
+    pub row_label_position: LabelPosition,
+
+    /// Ranges of column widths in the two heading regions, in 1/96" units.
+    pub heading_widths: EnumMap<HeadingRegion, RangeInclusive<isize>>,
+
+    /// Kind of markers to use for footnotes.
+    pub footnote_marker_type: FootnoteMarkerType,
+
+    /// Where to put the footnote markers.
+    pub footnote_marker_position: FootnoteMarkerPosition,
+
+    /// Styles for areas of the pivot table.
+    pub areas: EnumMap<Area, AreaStyle>,
+
+    /// Styles for borders in the pivot table.
+    pub borders: EnumMap<Border, BorderStyle>,
+
+    pub print_all_layers: bool,
+
+    pub paginate_layers: bool,
+
+    pub shrink_to_fit: EnumMap<Axis2, bool>,
+
+    pub top_continuation: bool,
+
+    pub bottom_continuation: bool,
+
+    pub continuation: Option<String>,
+
+    pub n_orphan_lines: usize,
+}
+
+impl Look {
+    pub fn with_omit_empty(mut self, omit_empty: bool) -> Self {
+        self.hide_empty = omit_empty;
+        self
+    }
+    pub fn with_row_label_position(mut self, row_label_position: LabelPosition) -> Self {
+        self.row_label_position = row_label_position;
+        self
+    }
+    pub fn with_borders(mut self, borders: EnumMap<Border, BorderStyle>) -> Self {
+        self.borders = borders;
+        self
+    }
+}
+
+impl Default for Look {
+    fn default() -> Self {
+        Self {
+            name: None,
+            hide_empty: true,
+            row_label_position: LabelPosition::default(),
+            heading_widths: EnumMap::from_fn(|region| match region {
+                HeadingRegion::Rows => 36..=72,
+                HeadingRegion::Columns => 36..=120,
+            }),
+            footnote_marker_type: FootnoteMarkerType::default(),
+            footnote_marker_position: FootnoteMarkerPosition::default(),
+            areas: EnumMap::from_fn(AreaStyle::default_for_area),
+            borders: Border::default_borders(),
+            print_all_layers: false,
+            paginate_layers: false,
+            shrink_to_fit: EnumMap::from_fn(|_| false),
+            top_continuation: false,
+            bottom_continuation: false,
+            continuation: None,
+            n_orphan_lines: 0,
+        }
+    }
+}
+
+#[derive(thiserror::Error, Debug)]
+pub enum ParseLookError {
+    #[error(transparent)]
+    XmlError(#[from] DeError),
+
+    #[error(transparent)]
+    Utf8Error(#[from] Utf8Error),
+
+    #[error(transparent)]
+    BinError(#[from] binrw::Error),
+
+    #[error(transparent)]
+    IoError(#[from] std::io::Error),
+}
+
+impl Look {
+    pub fn shared_default() -> Arc<Look> {
+        static LOOK: OnceLock<Arc<Look>> = OnceLock::new();
+        LOOK.get_or_init(|| Arc::new(Look::default())).clone()
+    }
+
+    pub fn from_xml(xml: &str) -> Result<Self, ParseLookError> {
+        Ok(from_str::<TableProperties>(xml)
+            .map_err(ParseLookError::from)?
+            .into())
+    }
+
+    pub fn from_binary(tlo: &[u8]) -> Result<Self, ParseLookError> {
+        parse_tlo(tlo).map_err(ParseLookError::from)
+    }
+
+    pub fn from_data(data: &[u8]) -> Result<Self, ParseLookError> {
+        if data.starts_with(b"\xff\xff\0\0") {
+            Self::from_binary(data)
+        } else {
+            Self::from_xml(str::from_utf8(data).map_err(ParseLookError::from)?)
+        }
+    }
+
+    pub fn from_reader<R>(mut reader: R) -> Result<Self, ParseLookError>
+    where
+        R: Read,
+    {
+        let mut buffer = Vec::new();
+        reader
+            .read_to_end(&mut buffer)
+            .map_err(ParseLookError::from)?;
+        Self::from_data(&buffer)
+    }
+}
+
+/// Position for group labels.
+#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
+pub enum LabelPosition {
+    /// Hierarchically enclosing the categories.
+    ///
+    /// For column labels, group labels appear above the categories.  For row
+    /// labels, group labels appear to the left of the categories.
+    ///
+    /// ```text
+    /// ┌────┬──────────────┐   ┌─────────┬──────────┐
+    /// │    │    nested    │   │         │ columns  │
+    /// │    ├────┬────┬────┤   ├──────┬──┼──────────┤
+    /// │    │ a1 │ a2 │ a3 │   │      │a1│...data...│
+    /// ├────┼────┼────┼────┤   │nested│a2│...data...│
+    /// │    │data│data│data│   │      │a3│...data...│
+    /// │    │ .  │ .  │ .  │   └──────┴──┴──────────┘
+    /// │rows│ .  │ .  │ .  │
+    /// │    │ .  │ .  │ .  │
+    /// └────┴────┴────┴────┘
+    /// ```
+    #[serde(rename = "nested")]
+    Nested,
+
+    /// In the corner (row labels only).
+    ///
+    /// ```text
+    /// ┌──────┬──────────┐
+    /// │corner│ columns  │
+    /// ├──────┼──────────┤
+    /// │    a1│...data...│
+    /// │    a2│...data...│
+    /// │    a3│...data...│
+    /// └──────┴──────────┘
+    /// ```
+    #[default]
+    #[serde(rename = "inCorner")]
+    Corner,
+}
+
+/// The heading region of a rendered pivot table:
+///
+/// ```text
+/// ┌──────────────────┬─────────────────────────────────────────────────┐
+/// │                  │                  column headings                │
+/// │                  ├─────────────────────────────────────────────────┤
+/// │      corner      │                                                 │
+/// │       and        │                                                 │
+/// │   row headings   │                      data                       │
+/// │                  │                                                 │
+/// │                  │                                                 │
+/// └──────────────────┴─────────────────────────────────────────────────┘
+/// ```
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Enum, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum HeadingRegion {
+    Rows,
+    Columns,
+}
+
+impl HeadingRegion {
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            HeadingRegion::Rows => "rows",
+            HeadingRegion::Columns => "columns",
+        }
+    }
+}
+
+impl Display for HeadingRegion {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.as_str())
+    }
+}
+
+impl From<Axis2> for HeadingRegion {
+    fn from(axis: Axis2) -> Self {
+        match axis {
+            Axis2::X => HeadingRegion::Columns,
+            Axis2::Y => HeadingRegion::Rows,
+        }
+    }
+}
+
+#[derive(Clone, Debug, PartialEq, Serialize)]
+pub struct AreaStyle {
+    pub cell_style: CellStyle,
+    pub font_style: FontStyle,
+}
+
+impl AreaStyle {
+    pub fn default_for_area(area: Area) -> Self {
+        Self {
+            cell_style: CellStyle::default_for_area(area),
+            font_style: FontStyle::default_for_area(area),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Serialize, PartialEq)]
+pub struct CellStyle {
+    /// `None` means "mixed" alignment: align strings to the left, numbers to
+    /// the right.
+    pub horz_align: Option<HorzAlign>,
+    pub vert_align: VertAlign,
+
+    /// Margins in 1/96" units.
+    ///
+    /// `margins[Axis2::X][0]` is the left margin.
+    /// `margins[Axis2::X][1]` is the right margin.
+    /// `margins[Axis2::Y][0]` is the top margin.
+    /// `margins[Axis2::Y][1]` is the bottom margin.
+    pub margins: EnumMap<Axis2, [i32; 2]>,
+}
+
+impl Default for CellStyle {
+    fn default() -> Self {
+        Self::default_for_area(Area::default())
+    }
+}
+
+impl CellStyle {
+    pub fn default_for_area(area: Area) -> Self {
+        use HorzAlign::*;
+        use VertAlign::*;
+        let (horz_align, vert_align, hmargins, vmargins) = match area {
+            Area::Title => (Some(Center), Middle, [8, 11], [1, 8]),
+            Area::Caption => (Some(Left), Top, [8, 11], [1, 1]),
+            Area::Footer => (Some(Left), Top, [11, 8], [2, 3]),
+            Area::Corner => (Some(Left), Bottom, [8, 11], [1, 1]),
+            Area::Labels(Axis2::X) => (Some(Center), Bottom, [8, 11], [1, 3]),
+            Area::Labels(Axis2::Y) => (Some(Left), Top, [8, 11], [1, 3]),
+            Area::Data(_) => (None, Top, [8, 11], [1, 1]),
+            Area::Layers => (Some(Left), Bottom, [8, 11], [1, 3]),
+        };
+        Self {
+            horz_align,
+            vert_align,
+            margins: enum_map! { Axis2::X => hmargins, Axis2::Y => vmargins },
+        }
+    }
+    pub fn with_horz_align(self, horz_align: Option<HorzAlign>) -> Self {
+        Self { horz_align, ..self }
+    }
+    pub fn with_vert_align(self, vert_align: VertAlign) -> Self {
+        Self { vert_align, ..self }
+    }
+    pub fn with_margins(self, margins: EnumMap<Axis2, [i32; 2]>) -> Self {
+        Self { margins, ..self }
+    }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Deserialize, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum HorzAlign {
+    /// Right aligned.
+    Right,
+
+    /// Left aligned.
+    Left,
+
+    /// Centered.
+    Center,
+
+    /// Align the decimal point at the specified position.
+    Decimal {
+        /// Decimal offset from the right side of the cell, in 1/96" units.
+        offset: f64,
+
+        /// Decimal character.
+        decimal: Decimal,
+    },
+}
+
+impl HorzAlign {
+    pub fn for_mixed(var_type: VarType) -> Self {
+        match var_type {
+            VarType::Numeric => Self::Right,
+            VarType::String => Self::Left,
+        }
+    }
+
+    pub fn as_str(&self) -> Option<&'static str> {
+        match self {
+            HorzAlign::Right => Some("right"),
+            HorzAlign::Left => Some("left"),
+            HorzAlign::Center => Some("center"),
+            HorzAlign::Decimal { .. } => None,
+        }
+    }
+}
+
+/// Unknown horizontal alignment.
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub struct UnknownHorzAlign;
+
+impl FromStr for HorzAlign {
+    type Err = UnknownHorzAlign;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        if s.eq_ignore_ascii_case("left") {
+            Ok(Self::Left)
+        } else if s.eq_ignore_ascii_case("center") {
+            Ok(Self::Center)
+        } else if s.eq_ignore_ascii_case("right") {
+            Ok(Self::Right)
+        } else {
+            Err(UnknownHorzAlign)
+        }
+    }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum VertAlign {
+    /// Top alignment.
+    Top,
+
+    /// Centered,
+    Middle,
+
+    /// Bottom alignment.
+    Bottom,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+pub struct FontStyle {
+    pub bold: bool,
+    pub italic: bool,
+    pub underline: bool,
+    pub font: String,
+    pub fg: Color,
+    pub bg: Color,
+
+    /// In 1/72" units.
+    pub size: i32,
+}
+
+impl Default for FontStyle {
+    fn default() -> Self {
+        FontStyle {
+            bold: false,
+            italic: false,
+            underline: false,
+            font: String::from("Sans Serif"),
+            fg: Color::BLACK,
+            bg: Color::WHITE,
+            size: 9,
+        }
+    }
+}
+
+impl FontStyle {
+    pub fn default_for_area(area: Area) -> Self {
+        Self::default().with_bold(area == Area::Title)
+    }
+    pub fn with_size(self, size: i32) -> Self {
+        Self { size, ..self }
+    }
+    pub fn with_bold(self, bold: bool) -> Self {
+        Self { bold, ..self }
+    }
+    pub fn with_italic(self, italic: bool) -> Self {
+        Self { italic, ..self }
+    }
+    pub fn with_underline(self, underline: bool) -> Self {
+        Self { underline, ..self }
+    }
+    pub fn with_font(self, font: impl Into<String>) -> Self {
+        Self {
+            font: font.into(),
+            ..self
+        }
+    }
+    pub fn with_fg(self, fg: Color) -> Self {
+        Self { fg, ..self }
+    }
+    pub fn with_bg(self, fg: Color) -> Self {
+        Self { fg, ..self }
+    }
+}
+
+#[derive(Copy, Clone, PartialEq, Eq)]
+pub struct Color {
+    pub alpha: u8,
+    pub r: u8,
+    pub g: u8,
+    pub b: u8,
+}
+
+impl Color {
+    pub const BLACK: Color = Color::new(0, 0, 0);
+    pub const WHITE: Color = Color::new(255, 255, 255);
+    pub const RED: Color = Color::new(255, 0, 0);
+    pub const BLUE: Color = Color::new(0, 0, 255);
+    pub const TRANSPARENT: Color = Color::new(0, 0, 0).with_alpha(0);
+
+    pub const fn new(r: u8, g: u8, b: u8) -> Self {
+        Self {
+            alpha: 255,
+            r,
+            g,
+            b,
+        }
+    }
+
+    pub const fn with_alpha(self, alpha: u8) -> Self {
+        Self { alpha, ..self }
+    }
+
+    pub const fn without_alpha(self) -> Self {
+        self.with_alpha(255)
+    }
+
+    /// Displays opaque colors as `#rrggbb` and others as `rgb(r, g, b, alpha)`.
+    pub fn display_css(&self) -> DisplayCss {
+        DisplayCss(*self)
+    }
+
+    pub fn into_rgb(&self) -> (u8, u8, u8) {
+        (self.r, self.g, self.b)
+    }
+
+    pub fn into_rgb16(&self) -> (u16, u16, u16) {
+        (
+            self.r as u16 * 257,
+            self.g as u16 * 257,
+            self.b as u16 * 257,
+        )
+    }
+}
+
+impl Debug for Color {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.display_css())
+    }
+}
+
+impl From<Rgba8> for Color {
+    fn from(Rgba8 { r, g, b, a }: Rgba8) -> Self {
+        Self::new(r, g, b).with_alpha(a)
+    }
+}
+
+impl FromStr for Color {
+    type Err = color::ParseError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        fn is_bare_hex(s: &str) -> bool {
+            let s = s.trim();
+            s.chars().count() == 6 && s.chars().all(|c| c.is_ascii_hexdigit())
+        }
+        let color: AlphaColor<Srgb> = match s.parse() {
+            Err(_) if is_bare_hex(s) => ("#".to_owned() + s).parse(),
+            Err(_) if s.trim().eq_ignore_ascii_case("transparent") => Ok(TRANSPARENT),
+            other => other,
+        }?;
+        Ok(color.to_rgba8().into())
+    }
+}
+
+impl Serialize for Color {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_str(&self.display_css().to_small_string::<32>())
+    }
+}
+
+impl<'de> Deserialize<'de> for Color {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        struct ColorVisitor;
+
+        impl<'de> Visitor<'de> for ColorVisitor {
+            type Value = Color;
+
+            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+                formatter.write_str("\"#rrggbb\" or \"rrggbb\" or web color name")
+            }
+
+            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
+            where
+                E: serde::de::Error,
+            {
+                v.parse().map_err(E::custom)
+            }
+        }
+
+        deserializer.deserialize_str(ColorVisitor)
+    }
+}
+
+pub struct DisplayCss(Color);
+
+impl Display for DisplayCss {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let Color { alpha, r, g, b } = self.0;
+        match alpha {
+            255 => write!(f, "#{r:02x}{g:02x}{b:02x}"),
+            _ => write!(f, "rgb({r}, {g}, {b}, {:.2})", alpha as f64 / 255.0),
+        }
+    }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Deserialize)]
+pub struct BorderStyle {
+    #[serde(rename = "@borderStyleType")]
+    pub stroke: Stroke,
+
+    #[serde(rename = "@color")]
+    pub color: Color,
+}
+
+impl Serialize for BorderStyle {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        let mut s = serializer.serialize_struct("BorderStyle", 2)?;
+        s.serialize_field("stroke", &self.stroke)?;
+        s.serialize_field("color", &self.color)?;
+        s.end()
+    }
+}
+
+impl From<Stroke> for BorderStyle {
+    fn from(value: Stroke) -> Self {
+        Self::new(value)
+    }
+}
+
+impl BorderStyle {
+    pub const fn new(stroke: Stroke) -> Self {
+        Self {
+            stroke,
+            color: Color::BLACK,
+        }
+    }
+
+    pub const fn none() -> Self {
+        Self::new(Stroke::None)
+    }
+
+    pub fn is_none(&self) -> bool {
+        self.stroke.is_none()
+    }
+
+    /// Returns a border style that "combines" the two arguments, that is, that
+    /// gives a reasonable choice for a rule for different reasons should have
+    /// both styles.
+    pub fn combine(self, other: BorderStyle) -> Self {
+        Self {
+            stroke: self.stroke.combine(other.stroke),
+            color: self.color,
+        }
+    }
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Enum, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub enum Stroke {
+    None,
+    Solid,
+    Dashed,
+    Thick,
+    Thin,
+    Double,
+}
+
+impl Stroke {
+    pub fn is_none(&self) -> bool {
+        self == &Self::None
+    }
+
+    /// Returns a stroke that "combines" the two arguments, that is, that gives
+    /// a reasonable stroke choice for a rule for different reasons should have
+    /// both styles.
+    pub fn combine(self, other: Stroke) -> Self {
+        self.max(other)
+    }
+}
index 1c3c64d0316212e44f70da8fe1bfade16760af58..1c7e90d3f0e5c23f7afb43816795c87a48daf63d 100644 (file)
@@ -22,9 +22,11 @@ use serde::{Deserialize, de::Visitor};
 use crate::{
     format::Decimal,
     output::pivot::{
-        Area, AreaStyle, Axis2, Border, BorderStyle, BoxBorder, Color, FootnoteMarkerPosition,
-        FootnoteMarkerType, HeadingRegion, HorzAlign, LabelPosition, Look, RowColBorder, RowParity,
-        VertAlign,
+        Axis2, FootnoteMarkerPosition, FootnoteMarkerType,
+        look::{
+            self, Area, AreaStyle, Border, BorderStyle, BoxBorder, Color, HeadingRegion, HorzAlign,
+            LabelPosition, Look, RowColBorder, RowParity, VertAlign,
+        },
     },
 };
 use thiserror::Error as ThisError;
@@ -205,7 +207,7 @@ struct CellStyle {
 impl CellStyle {
     fn as_area_style(&self, data_row: RowParity) -> AreaStyle {
         AreaStyle {
-            cell_style: super::CellStyle {
+            cell_style: look::CellStyle {
                 horz_align: match self.text_alignment {
                     TextAlignment::Left => Some(HorzAlign::Left),
                     TextAlignment::Right => Some(HorzAlign::Right),
@@ -226,7 +228,7 @@ impl CellStyle {
                     Axis2::Y => [self.margin_top.as_px_i32(), self.margin_bottom.as_px_i32()],
                 },
             },
-            font_style: super::FontStyle {
+            font_style: look::FontStyle {
                 bold: self.font_weight == FontWeight::Bold,
                 italic: self.font_style == FontStyle::Italic,
                 underline: self.font_underline == FontUnderline::Underline,
@@ -452,9 +454,12 @@ mod tests {
     use quick_xml::de::from_str;
 
     use crate::output::pivot::{
-        Area, AreaStyle, Axis2, Border, BorderStyle, BoxBorder, CellStyle, Color, FontStyle,
-        FootnoteMarkerPosition, FootnoteMarkerType, HeadingRegion, HorzAlign, LabelPosition, Look,
-        RowColBorder, RowParity, Stroke, VertAlign,
+        Axis2, FootnoteMarkerPosition, FootnoteMarkerType,
+        look::{
+            Area, AreaStyle, Border, BorderStyle, BoxBorder, CellStyle, Color, FontStyle,
+            HeadingRegion, HorzAlign, LabelPosition, Look, RowColBorder, RowParity, Stroke,
+            VertAlign,
+        },
         look_xml::{Length, LengthParseError, TableProperties},
     };
 
index 33bfc4c6b7ab852e8a64acc5daef9c1b1bd800b1..317da926e34d6beb07f3124a46ba8ee8238a0e37 100644 (file)
@@ -20,13 +20,17 @@ use enum_map::{EnumMap, enum_map};
 use itertools::Itertools;
 
 use crate::output::{
-    pivot::{HeadingRegion, LabelPosition, Path, RowParity},
+    pivot::{
+        Footnote, Path,
+        look::{HeadingRegion, LabelPosition, RowParity},
+    },
     table::{CellInner, CellPos, CellRect, Table},
 };
 
-use super::{
-    Area, Axis2, Axis3, Border, BorderStyle, BoxBorder, Color, Dimension, Footnote, PivotTable,
-    RowColBorder, Stroke, Value, value::IntoValueOptions,
+use crate::output::pivot::{
+    Axis2, Axis3, Dimension, PivotTable, Value,
+    look::{Area, Border, BorderStyle, BoxBorder, Color, RowColBorder, Stroke},
+    value::IntoValueOptions,
 };
 
 /// All of the combinations of dimensions along an axis.
index 23df42aca4b1edb5ab1e89c500875e3af9124d64..8149b4fd435c69b42c6c74330b3eb7a69c7361b9 100644 (file)
@@ -27,9 +27,12 @@ use crate::output::{
         spv::SpvDriver,
     },
     pivot::{
-        Area, Axis2, Border, BorderStyle, Class, Color, Dimension, Footnote,
-        FootnoteMarkerPosition, FootnoteMarkerType, Footnotes, Group, HeadingRegion, LabelPosition,
-        Look, PivotTable, RowColBorder, Stroke,
+        Axis2, Class, Dimension, Footnote, FootnoteMarkerPosition, FootnoteMarkerType, Footnotes,
+        Group, PivotTable,
+        look::{
+            Area, Border, BorderStyle, Color, HeadingRegion, HorzAlign, LabelPosition, Look,
+            RowColBorder, Stroke,
+        },
     },
 };
 
@@ -77,7 +80,7 @@ fn d1_r() {
 
 fn test_look() -> Look {
     let mut look = Look::default();
-    look.areas[Area::Title].cell_style.horz_align = Some(super::HorzAlign::Left);
+    look.areas[Area::Title].cell_style.horz_align = Some(HorzAlign::Left);
     look.areas[Area::Title].font_style.bold = false;
     look
 }
index 39c6c3240d04f878baada2d0649fd2a52b9b5bb1..c1329afe18fb8a8a74e1e9c89701568a074eddd9 100644 (file)
@@ -19,12 +19,12 @@ use std::{fmt::Debug, io::Cursor};
 use crate::{
     format::Decimal,
     output::pivot::{
-        Axis2, Border, BoxBorder, FootnoteMarkerPosition, FootnoteMarkerType, HeadingRegion,
-        LabelPosition, RowColBorder,
+        Axis2, FootnoteMarkerPosition, FootnoteMarkerType,
+        look::{self, Border, BoxBorder, HeadingRegion, LabelPosition, RowColBorder},
     },
 };
 
-use super::{Area, BorderStyle, Color, HorzAlign, Look, Stroke, VertAlign};
+use crate::output::pivot::look::{Area, BorderStyle, Color, HorzAlign, Look, Stroke, VertAlign};
 use binrw::{BinRead, BinResult, Error as BinError, binread};
 use enum_map::enum_map;
 
@@ -97,7 +97,7 @@ impl From<TableLook> for Look {
                 FootnoteMarkerPosition::Superscript
             },
             areas: enum_map! {
-                    Area::Title => super::AreaStyle::from_tlo(look.pv_cell_style.title_color, &look.pv_text_style.title_style),
+                    Area::Title => look::AreaStyle::from_tlo(look.pv_cell_style.title_color, &look.pv_text_style.title_style),
                     Area::Caption => (&look.pv_text_style.caption).into(),
                     Area::Footer => (&look.pv_text_style.footer).into(),
                     Area::Corner => (&look.pv_text_style.corner).into(),
@@ -332,16 +332,16 @@ struct MostAreas {
     style: AreaStyle,
 }
 
-impl From<&MostAreas> for super::AreaStyle {
+impl From<&MostAreas> for look::AreaStyle {
     fn from(area: &MostAreas) -> Self {
         Self::from_tlo(area.color, &area.style)
     }
 }
 
-impl super::AreaStyle {
+impl look::AreaStyle {
     fn from_tlo(bg: Color, style: &AreaStyle) -> Self {
         Self {
-            cell_style: super::CellStyle {
+            cell_style: look::CellStyle {
                 horz_align: match style.halign {
                     0 => Some(HorzAlign::Left),
                     1 => Some(HorzAlign::Right),
@@ -367,7 +367,7 @@ impl super::AreaStyle {
                     }
                 },
             },
-            font_style: super::FontStyle {
+            font_style: look::FontStyle {
                 bold: style.weight > 400,
                 italic: style.italic,
                 underline: style.underline,
index 6d1a87cba8e2674e615a4423a2d343f35776ad32..25391f0e1b86fca089d40c08014c72deca2ec5ec 100644 (file)
@@ -15,7 +15,8 @@ use crate::{
     data::{Datum, EncodedString},
     format::{DATETIME40_0, F8_2, F40, Format, Type, UncheckedFormat},
     output::pivot::{
-        CellStyle, DisplayMarker, FontStyle, Footnote, FootnoteMarkerType, PivotTable,
+        DisplayMarker, Footnote, FootnoteMarkerType, PivotTable,
+        look::{CellStyle, FontStyle},
     },
     settings::{Settings, Show},
     spv::html::Markup,
index c1e6e84656a5ad9da6346ce752a66710c0222abb..bdb30d6138b9af6df4636dcf6530bd4389ff2928 100644 (file)
@@ -24,11 +24,13 @@ use itertools::{Itertools, interleave};
 use num::Integer;
 use smallvec::SmallVec;
 
-use crate::output::pivot::VertAlign;
-use crate::output::table::{CellPos, CellRect, DrawCell};
-
-use super::pivot::{Axis2, BorderStyle, Coord2, Look, PivotTable, Rect2, Stroke};
-use super::table::{Content, Table};
+use crate::output::{
+    pivot::{
+        Axis2, Coord2, PivotTable, Rect2,
+        look::{BorderStyle, Look, Stroke, VertAlign},
+    },
+    table::{CellPos, CellRect, Content, DrawCell, Table},
+};
 
 /// Parameters for rendering a table_item to a device.
 ///
index 0dd010931a88b7997b0a54cee091ed7e6c852026..eb9b7d04bb9630f83656cef897adca35d625de70 100644 (file)
@@ -37,16 +37,15 @@ use ndarray::{Array, Array2};
 
 use crate::{
     output::pivot::{
-        CellStyle, FontStyle, Footnote, HorzAlign,
-        value::{DisplayValue, ValueInner},
+        Axis2, Footnote, Value,
+        look::{
+            Area, AreaStyle, Border, BorderStyle, CellStyle, FontStyle, HeadingRegion, HorzAlign,
+        },
+        value::{DisplayValue, ValueInner, ValueOptions},
     },
     spv::html,
 };
 
-use super::pivot::{
-    Area, AreaStyle, Axis2, Border, BorderStyle, HeadingRegion, Value, value::ValueOptions,
-};
-
 /// The `(x,y)` position of a cell in a [Table].
 #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
 pub struct CellPos {
index 5bb412c311b2ba8f9683ee31c19043887885bd41..978c3c8ff6387946b613ced38c4caeee9ad037da 100644 (file)
@@ -23,7 +23,7 @@ use serde::Serialize;
 use crate::{
     format::{F8_2, Format, Settings as FormatSettings},
     message::Severity,
-    output::pivot::Look,
+    output::pivot::look::Look,
 };
 
 /// Whether to show variable or value labels or the underlying value or variable
index 8b0c2dca97f6b83e54f5cada0e6bc43675193076..fc2b0fff03e3b63e80a204bf40e2260d64ddebde 100644 (file)
@@ -31,9 +31,8 @@ use zip::{ZipArchive, result::ZipError};
 use crate::{
     crypto::EncryptedFile,
     output::{
-        Details, Item, SpvInfo, SpvMembers, Text,
-        page::{self},
-        pivot::{Axis2, Length, Look, TableProperties, Value},
+        Details, Item, SpvInfo, SpvMembers, Text, page,
+        pivot::{Axis2, Length, TableProperties, Value, look::Look},
     },
     spv::read::{
         html::Document,
index 5733b2869e3bb300aaf2b431a3e4fe7f28df5de1..76d14190d1eebf2bb139c6ef6bce0df14abe2d7d 100644 (file)
@@ -8,7 +8,7 @@ use std::{
 use itertools::Itertools;
 
 use crate::{
-    output::pivot::{FontStyle, HorzAlign},
+    output::pivot::look::{FontStyle, HorzAlign},
     spv::read::html::Style,
 };
 
@@ -256,7 +256,7 @@ mod tests {
     use std::borrow::Cow;
 
     use crate::{
-        output::pivot::{Color, FontStyle, HorzAlign},
+        output::pivot::look::{Color, FontStyle, HorzAlign},
         spv::read::css::{Lexer, Token},
     };
 
index ec896e64f12b359b173b8935fab9704ad7cea453..39a01351d0540deabbb998bca70018b461f8f8c5 100644 (file)
@@ -39,7 +39,10 @@ use quick_xml::{
 };
 use serde::{Deserialize, Deserializer, Serialize, ser::SerializeMap};
 
-use crate::output::pivot::{CellStyle, Color, FontStyle, HorzAlign, Value};
+use crate::output::pivot::{
+    Value,
+    look::{CellStyle, Color, FontStyle, HorzAlign},
+};
 
 fn lowercase<'a>(s: &'a str) -> Cow<'a, str> {
     if s.chars().any(|c| c.is_ascii_uppercase()) {
index baaf0d8a6a6e598fc8d451346e3cd9e1159382ce..f926961d956b5056a92d57afa0a0c3ab40b51031 100644 (file)
@@ -35,9 +35,12 @@ use crate::{
     data::Datum,
     format::{self, Decimal::Dot, F8_0, F40_2, Type, UncheckedFormat},
     output::pivot::{
-        self, Area, AreaStyle, Axis2, Axis3, Category, CategoryLocator, CellStyle, Color,
-        Dimension, Group, HeadingRegion, HorzAlign, Leaf, Length, Look, PivotTable, RowParity,
-        Value, VertAlign,
+        self, Axis2, Axis3, Category, CategoryLocator, Dimension, Group, Leaf, Length, PivotTable,
+        Value,
+        look::{
+            self, Area, AreaStyle, CellStyle, Color, HeadingRegion, HorzAlign, Look, RowParity,
+            VertAlign,
+        },
         value::{NumberValue, ValueInner},
     },
     spv::read::legacy_bin::DataValue,
@@ -1912,7 +1915,7 @@ impl Style {
         fg: Option<&Style>,
         bg: Option<&Style>,
         cell_style: &mut CellStyle,
-        font_style: &mut pivot::FontStyle,
+        font_style: &mut look::FontStyle,
     ) {
         if let Some(fg) = fg {
             if let Some(weight) = fg.font_weight {
index 45b4c37a69fbe719d95665ec34cb767251472360..39cdbe031f7838f432459d5456145fab98ded601 100644 (file)
@@ -21,10 +21,13 @@ use crate::{
         Width,
     },
     output::pivot::{
-        self, AreaStyle, Axis2, Axis3, BoxBorder, Color, FootnoteMarkerPosition,
-        FootnoteMarkerType, Footnotes, Group, HeadingRegion, HorzAlign, LabelPosition, Look,
-        PivotTable, PivotTableMetadata, PivotTableStyle, PrecomputedIndex, RowColBorder, RowParity,
-        Stroke, VertAlign, parse_bool,
+        self, Axis2, Axis3, FootnoteMarkerPosition, FootnoteMarkerType, Footnotes, Group,
+        PivotTable, PivotTableMetadata, PivotTableStyle, PrecomputedIndex,
+        look::{
+            self, AreaStyle, BoxBorder, Color, HeadingRegion, HorzAlign, LabelPosition, Look,
+            RowColBorder, RowParity, Stroke, VertAlign,
+        },
+        parse_bool,
         value::{StringValue, TemplateValue, ValueStyle, VariableValue},
     },
     settings::Show,
@@ -344,20 +347,20 @@ struct Areas {
 }
 
 impl Areas {
-    fn decode(&self, encoding: &'static Encoding) -> EnumMap<pivot::Area, AreaStyle> {
+    fn decode(&self, encoding: &'static Encoding) -> EnumMap<look::Area, AreaStyle> {
         EnumMap::from_fn(|area| {
             let index = match area {
-                pivot::Area::Title => 0,
-                pivot::Area::Caption => 1,
-                pivot::Area::Footer => 2,
-                pivot::Area::Corner => 3,
-                pivot::Area::Labels(Axis2::X) => 4,
-                pivot::Area::Labels(Axis2::Y) => 5,
-                pivot::Area::Data(_) => 6,
-                pivot::Area::Layers => 7,
+                look::Area::Title => 0,
+                look::Area::Caption => 1,
+                look::Area::Footer => 2,
+                look::Area::Corner => 3,
+                look::Area::Labels(Axis2::X) => 4,
+                look::Area::Labels(Axis2::Y) => 5,
+                look::Area::Data(_) => 6,
+                look::Area::Layers => 7,
             };
             let data_row = match area {
-                pivot::Area::Data(row) => row,
+                look::Area::Data(row) => row,
                 _ => RowParity::default(),
             };
             self.areas[index].decode(encoding, data_row)
@@ -411,7 +414,7 @@ struct Area {
 impl Area {
     fn decode(&self, encoding: &'static Encoding, data_row: RowParity) -> AreaStyle {
         AreaStyle {
-            cell_style: pivot::CellStyle {
+            cell_style: look::CellStyle {
                 horz_align: match self.halign {
                     0 => Some(HorzAlign::Center),
                     2 => Some(HorzAlign::Left),
@@ -428,7 +431,7 @@ impl Area {
                     Axis2::Y => [self.margins.top_margin, self.margins.bottom_margin]
                 },
             },
-            font_style: pivot::FontStyle {
+            font_style: look::FontStyle {
                 bold: (self.style & 1) != 0,
                 italic: (self.style & 2) != 0,
                 underline: self.underline,
@@ -474,8 +477,8 @@ struct Borders {
 }
 
 impl Borders {
-    fn decode(&self) -> EnumMap<pivot::Border, pivot::BorderStyle> {
-        let mut borders = pivot::Border::default_borders();
+    fn decode(&self) -> EnumMap<look::Border, look::BorderStyle> {
+        let mut borders = look::Border::default_borders();
         for border in &self.borders {
             if let Some((border, style)) = border.decode() {
                 borders[border] = style;
@@ -498,27 +501,27 @@ struct Border {
 }
 
 impl Border {
-    fn decode(&self) -> Option<(pivot::Border, pivot::BorderStyle)> {
+    fn decode(&self) -> Option<(look::Border, look::BorderStyle)> {
         let border = match self.index {
-            0 => pivot::Border::Title,
-            1 => pivot::Border::OuterFrame(BoxBorder::Left),
-            2 => pivot::Border::OuterFrame(BoxBorder::Top),
-            3 => pivot::Border::OuterFrame(BoxBorder::Right),
-            4 => pivot::Border::OuterFrame(BoxBorder::Bottom),
-            5 => pivot::Border::InnerFrame(BoxBorder::Left),
-            6 => pivot::Border::InnerFrame(BoxBorder::Top),
-            7 => pivot::Border::InnerFrame(BoxBorder::Right),
-            8 => pivot::Border::InnerFrame(BoxBorder::Bottom),
-            9 => pivot::Border::DataLeft,
-            10 => pivot::Border::DataLeft,
-            11 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)),
-            12 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)),
-            13 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)),
-            14 => pivot::Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)),
-            15 => pivot::Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)),
-            16 => pivot::Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)),
-            17 => pivot::Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)),
-            18 => pivot::Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+            0 => look::Border::Title,
+            1 => look::Border::OuterFrame(BoxBorder::Left),
+            2 => look::Border::OuterFrame(BoxBorder::Top),
+            3 => look::Border::OuterFrame(BoxBorder::Right),
+            4 => look::Border::OuterFrame(BoxBorder::Bottom),
+            5 => look::Border::InnerFrame(BoxBorder::Left),
+            6 => look::Border::InnerFrame(BoxBorder::Top),
+            7 => look::Border::InnerFrame(BoxBorder::Right),
+            8 => look::Border::InnerFrame(BoxBorder::Bottom),
+            9 => look::Border::DataLeft,
+            10 => look::Border::DataLeft,
+            11 => look::Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+            12 => look::Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+            13 => look::Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+            14 => look::Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+            15 => look::Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+            16 => look::Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)),
+            17 => look::Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)),
+            18 => look::Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)),
             _ => return None,
         };
 
@@ -538,7 +541,7 @@ impl Border {
         )
         .with_alpha((self.color >> 24) as u8);
 
-        Some((border, pivot::BorderStyle { stroke, color }))
+        Some((border, look::BorderStyle { stroke, color }))
     }
 }
 
@@ -612,16 +615,16 @@ impl Sizing {
         &self,
         column_widths: &[i32],
         row_heights: &[i32],
-    ) -> EnumMap<Axis2, Option<Box<pivot::Sizing>>> {
+    ) -> EnumMap<Axis2, Option<Box<look::Sizing>>> {
         fn decode_axis(
             widths: &[i32],
             breaks: &[u32],
             keeps: &[(i32, i32)],
-        ) -> Option<Box<pivot::Sizing>> {
+        ) -> Option<Box<look::Sizing>> {
             if widths.is_empty() && breaks.is_empty() && keeps.is_empty() {
                 None
             } else {
-                Some(Box::new(pivot::Sizing {
+                Some(Box::new(look::Sizing {
                     widths: widths.into(),
                     breaks: breaks.into_iter().map(|b| *b as usize).collect(),
                     keeps: keeps
@@ -1376,22 +1379,22 @@ struct ValueModsV3 {
 
 impl ValueMods {
     fn decode(&self, encoding: &'static Encoding, footnotes: &pivot::Footnotes) -> ValueStyle {
-        let font_style =
-            self.v3
-                .style_pair
-                .font_style
-                .as_ref()
-                .map(|font_style| pivot::FontStyle {
-                    bold: font_style.bold,
-                    italic: font_style.italic,
-                    underline: font_style.underline,
-                    font: font_style.typeface.decode(encoding),
-                    fg: font_style.fg,
-                    bg: font_style.bg,
-                    size: (font_style.size as i32) * 4 / 3,
-                });
+        let font_style = self
+            .v3
+            .style_pair
+            .font_style
+            .as_ref()
+            .map(|font_style| look::FontStyle {
+                bold: font_style.bold,
+                italic: font_style.italic,
+                underline: font_style.underline,
+                font: font_style.typeface.decode(encoding),
+                fg: font_style.fg,
+                bg: font_style.bg,
+                size: (font_style.size as i32) * 4 / 3,
+            });
         let cell_style = self.v3.style_pair.cell_style.as_ref().map(|cell_style| {
-            pivot::CellStyle {
+            look::CellStyle {
                 horz_align: match cell_style.halign {
                     0 => Some(HorzAlign::Center),
                     2 => Some(HorzAlign::Left),
index e3d34602fd45ebb259dd5343c14bae5f73476828..1798874d0e4cd3c35b407ceb6ad8be9c39c2013b 100644 (file)
@@ -35,10 +35,13 @@ use crate::{
         Details, Item, Text,
         page::{ChartSize, PageSetup},
         pivot::{
-            Area, AreaStyle, Axis2, Axis3, Border, BorderStyle, BoxBorder, Category, CellStyle,
-            Color, Dimension, FontStyle, Footnote, FootnoteMarkerPosition, FootnoteMarkerType,
-            Footnotes, Group, HeadingRegion, HorzAlign, LabelPosition, Leaf, PivotTable,
-            RowColBorder, RowParity, Stroke, Value, VertAlign,
+            Axis2, Axis3, Category, Dimension, Footnote, FootnoteMarkerPosition,
+            FootnoteMarkerType, Footnotes, Group, Leaf, PivotTable, Value,
+            look::{
+                Area, AreaStyle, Border, BorderStyle, BoxBorder, CellStyle, Color, FontStyle,
+                HeadingRegion, HorzAlign, LabelPosition, RowColBorder, RowParity, Stroke,
+                VertAlign,
+            },
             value::{ValueInner, ValueStyle},
         },
     },