use std::{
collections::HashMap,
- fmt::{Display, Write},
+ fmt::{Debug, Display, Write},
iter::{once, repeat},
ops::{Index, IndexMut, Not, Range, RangeInclusive},
- str::from_utf8,
+ str::{from_utf8, FromStr},
sync::{Arc, OnceLock, Weak},
};
use chrono::NaiveDateTime;
+pub use color::ParseError as ParseColorError;
+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 serde::{de::Visitor, Deserialize};
use smallstr::SmallString;
use smallvec::{smallvec, SmallVec};
}
/// Table borders for styling purposes.
-#[derive(Copy, Clone, Debug, Enum)]
+#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)]
pub enum Border {
Title,
OuterFrame(BoxBorder),
}
/// The borders on a box.
-#[derive(Copy, Clone, Debug, Enum)]
+#[derive(Copy, Clone, Debug, Enum, PartialEq, Eq)]
pub enum BoxBorder {
Left,
Top,
pub name: Option<String>,
pub omit_empty: bool,
- pub row_labels_in_corner: bool,
+
+ pub row_label_position: RowLabelPosition,
/// Ranges of column widths in the two heading regions, in 1/96" units.
pub heading_widths: EnumMap<HeadingRegion, RangeInclusive<usize>>,
Self {
name: None,
omit_empty: true,
- row_labels_in_corner: true,
+ row_label_position: RowLabelPosition::default(),
heading_widths: EnumMap::from_fn(|region| match region {
HeadingRegion::RowHeadings => 36..=72,
HeadingRegion::ColumnHeadings => 36..=120,
}
}
+#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub enum RowLabelPosition {
+ Nested,
+
+ #[default]
+ InCorner,
+}
+
+mod look_xml {
+ use std::{fmt::Debug, num::ParseFloatError, str::FromStr};
+
+ use serde::{de::Visitor, Deserialize};
+
+ use crate::output::pivot::{
+ Color, FootnoteMarkerPosition, FootnoteMarkerType, RowLabelPosition, Stroke,
+ };
+ use thiserror::Error as ThisError;
+
+ #[derive(Deserialize, Debug)]
+ #[serde(rename_all = "camelCase")]
+ struct TableProperties {
+ #[serde(rename = "@name")]
+ name: Option<String>,
+ general_properties: GeneralProperties,
+ footnote_properties: FootnoteProperties,
+ cell_format_properties: CellFormatProperties,
+ border_properties: BorderProperties,
+ printing_properties: PrintingProperties,
+ }
+
+ #[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_dimension_labels: RowLabelPosition,
+ }
+
+ #[derive(Deserialize, Debug)]
+ #[serde(rename_all = "camelCase")]
+ struct FootnoteProperties {
+ #[serde(rename = "@markerPosition")]
+ marker_position: FootnoteMarkerPosition,
+
+ #[serde(rename = "@numberFormat")]
+ number_format: 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,
+ }
+
+ #[derive(Deserialize, Debug, Default)]
+ #[serde(rename_all = "camelCase")]
+ enum FontStyle {
+ #[default]
+ Regular,
+ Italic,
+ }
+
+ #[derive(Deserialize, Debug, Default)]
+ #[serde(rename_all = "camelCase")]
+ enum FontWeight {
+ #[default]
+ Regular,
+ Bold,
+ }
+
+ #[derive(Deserialize, Debug, Default)]
+ #[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(Default, PartialEq)]
+ struct Dimension(
+ /// In inches.
+ f64,
+ );
+
+ 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
}
}
-#[derive(Copy, Clone, Debug)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum VertAlign {
/// Top alignment.
Top,
size: i32,
}
-#[derive(Copy, Clone, Debug)]
+#[derive(Copy, Clone, PartialEq, Eq)]
pub struct Color {
alpha: u8,
r: u8,
impl Color {
const BLACK: Color = Color::new(0, 0, 0);
const WHITE: Color = Color::new(255, 255, 255);
+ const TRANSPARENT: Color = Color::new(0, 0, 0).with_alpha(0);
const fn new(r: u8, g: u8, b: u8) -> Self {
Self {
b,
}
}
+
+ const fn with_alpha(self, alpha: u8) -> Self {
+ Self { alpha, ..self }
+ }
+}
+
+impl Debug for Color {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let Color { alpha, r, g, b } = *self;
+ match alpha {
+ 255 => write!(f, "#{r:02x}{g:02x}{b:02x}"),
+ _ => write!(f, "rgb({r}, {g}, {b}, {:.2})", alpha as f64 / 255.0),
+ }
+ }
+}
+
+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(ParseColorError::UnknownColorSyntax) if is_bare_hex(s) => {
+ ("#".to_owned() + s).parse()
+ }
+ Err(ParseColorError::UnknownColorSyntax)
+ if s.trim().eq_ignore_ascii_case("transparent") =>
+ {
+ Ok(TRANSPARENT)
+ }
+ other => other,
+ }?;
+ Ok(color.to_rgba8().into())
+ }
+}
+
+#[cfg(test)]
+mod test {
+ use crate::output::pivot::Color;
+
+ #[test]
+ fn color() {
+ assert_eq!("#112233".parse(), Ok(Color::new(0x11, 0x22, 0x33)));
+ assert_eq!("112233".parse(), Ok(Color::new(0x11, 0x22, 0x33)));
+ assert_eq!("rgb(11,22,33)".parse(), Ok(Color::new(11, 22, 33)));
+ assert_eq!(
+ "rgba(11,22,33, 0.25)".parse(),
+ Ok(Color::new(11, 22, 33).with_alpha(64))
+ );
+ assert_eq!("lavender".parse(), Ok(Color::new(230, 230, 250)));
+ assert_eq!("transparent".parse(), Ok(Color::new(0, 0, 0).with_alpha(0)));
+ }
+}
+
+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_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(ColorVisitor)
+ }
}
#[derive(Copy, Clone, Debug)]
}
}
-#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Enum)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Enum, Deserialize)]
+#[serde(rename_all = "camelCase")]
pub enum Stroke {
None,
Solid,
}
}
-#[derive(Copy, Clone, Debug, Default)]
+#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
pub enum FootnoteMarkerType {
/// a, b, c, ...
#[default]
Numeric,
}
-#[derive(Copy, Clone, Debug, Default)]
+#[derive(Copy, Clone, Debug, Default, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
pub enum FootnoteMarkerPosition {
/// Subscripts.
#[default]