fies
authorBen Pfaff <blp@cs.stanford.edu>
Thu, 25 Dec 2025 19:17:31 +0000 (11:17 -0800)
committerBen Pfaff <blp@cs.stanford.edu>
Thu, 25 Dec 2025 19:17:31 +0000 (11:17 -0800)
rust/pspp/src/output/pivot/look.rs
rust/pspp/src/output/pivot/look_xml.rs

index 6b8411a1098b393b584eb93d5b1de1f67514aab0..8c7d725cc93a4c01637b9d6987a2fe6d47364034 100644 (file)
@@ -92,6 +92,16 @@ pub enum Area {
     Layers,
 }
 
+impl Area {
+    /// Returns the row parity for [Area::Data], or `None` for other areas.
+    pub fn row_parity(self) -> Option<RowParity> {
+        match self {
+            Area::Data(row_parity) => Some(row_parity),
+            _ => None,
+        }
+    }
+}
+
 impl Default for Area {
     fn default() -> Self {
         Self::Data(RowParity::default())
@@ -904,6 +914,11 @@ impl Color {
         }
     }
 
+    /// Returns an opaque color taken from `rgb`.
+    pub const fn new_u32(rgb: u32) -> Self {
+        Self::new((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8)
+    }
+
     /// Returns this color with the alpha channel set as specified.
     pub const fn with_alpha(self, alpha: u8) -> Self {
         Self { alpha, ..self }
@@ -1058,11 +1073,26 @@ impl BorderStyle {
         Self::new(Stroke::None)
     }
 
+    /// Returns a solid, black border style.
+    pub const fn solid() -> Self {
+        Self::new(Stroke::Solid)
+    }
+
     /// Returns whether this border style has no line.
     pub fn is_none(&self) -> bool {
         self.stroke.is_none()
     }
 
+    /// Returns this border style with the given `stroke`.
+    pub fn with_stroke(self, stroke: Stroke) -> Self {
+        Self { stroke, ..self }
+    }
+
+    /// Returns this border style with the given `color`.
+    pub fn with_color(self, color: Color) -> Self {
+        Self { color, ..self }
+    }
+
     /// 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.
index ecaf2c96f86fe038a12529a8312757c5b4a1ee5c..12757c623d703a03f848c3ba82145f687af83d62 100644 (file)
@@ -44,54 +44,47 @@ pub struct TableProperties {
 impl From<TableProperties> for Look {
     fn from(table_properties: TableProperties) -> Self {
         Self {
-                name: table_properties.name,
-                hide_empty: table_properties.general_properties.hide_empty_rows,
-                row_label_position: table_properties.general_properties.row_label_position,
-                heading_widths: enum_map! {
-                    HeadingRegion::Columns => table_properties.general_properties.minimum_column_width..=table_properties.general_properties.maximum_column_width,
-                    HeadingRegion::Rows => table_properties.general_properties.minimum_row_width..=table_properties.general_properties.maximum_row_width,
-                }.map(|_k, r|(*r.start()).try_into().unwrap_or_default()..=(*r.end()).try_into().unwrap_or_default()),
-                footnote_marker_type: table_properties.footnote_properties.marker_type,
-                footnote_marker_position: table_properties.footnote_properties.marker_position,
-                areas: enum_map! {
-                    Area::Title => table_properties.cell_format_properties.title.style.as_area_style(RowParity::Even),
-                    Area::Caption => table_properties.cell_format_properties.caption.style.as_area_style(RowParity::Even),
-                    Area::Footer => table_properties.cell_format_properties.footnotes.style.as_area_style(RowParity::Even),
-                    Area::Corner => table_properties.cell_format_properties.corner_labels.style.as_area_style(RowParity::Even),
-                    Area::Labels(Axis2::X) => table_properties.cell_format_properties.column_labels.style.as_area_style(RowParity::Even),
-                    Area::Labels(Axis2::Y) => table_properties.cell_format_properties.row_labels.style.as_area_style(RowParity::Even),
-                    Area::Data(row) => table_properties.cell_format_properties.data.style.as_area_style(row),
-                    Area::Layers => table_properties.cell_format_properties.layers.style.as_area_style(RowParity::Even),
-                },
+            name: table_properties.name,
+            hide_empty: table_properties.general_properties.hide_empty_rows,
+            row_label_position: table_properties.general_properties.row_label_position,
+            heading_widths: enum_map! {
+                HeadingRegion::Columns => table_properties.general_properties.minimum_column_width..=table_properties.general_properties.maximum_column_width,
+                HeadingRegion::Rows => table_properties.general_properties.minimum_row_width..=table_properties.general_properties.maximum_row_width,
+            }.map(|_k, r|(*r.start()).try_into().unwrap_or_default()..=(*r.end()).try_into().unwrap_or_default()),
+            footnote_marker_type: table_properties.footnote_properties.marker_type,
+            footnote_marker_position: table_properties.footnote_properties.marker_position,
+            areas: EnumMap::from_fn(|area| {
+                table_properties.cell_format_properties.get_style(area).as_area_style(area)
+            }),
             borders: table_properties.border_properties.decode(),
-                print_all_layers: table_properties.printing_properties.print_all_layers,
-                paginate_layers: table_properties
-                    .printing_properties
-                    .print_each_layer_on_separate_page,
-                shrink_to_fit: enum_map! {
-                    Axis2::X => table_properties.printing_properties.rescale_wide_table_to_fit_page,
-                    Axis2::Y => table_properties.printing_properties.rescale_long_table_to_fit_page,
-                },
+            print_all_layers: table_properties.printing_properties.print_all_layers,
+            paginate_layers: table_properties
+                .printing_properties
+                .print_each_layer_on_separate_page,
+            shrink_to_fit: enum_map! {
+                Axis2::X => table_properties.printing_properties.rescale_wide_table_to_fit_page,
+                Axis2::Y => table_properties.printing_properties.rescale_long_table_to_fit_page,
+            },
             show_continuations: [ table_properties
-                    .printing_properties
-                    .continuation_text_at_top,
-                 table_properties
-                    .printing_properties
-                    .continuation_text_at_bottom],
-                continuation: {
-                    let text = table_properties.printing_properties.continuation_text;
-                    if text.is_empty() {
-                        None
-                    } else {
-                        Some(text)
-                    }
-                },
-                n_orphan_lines: table_properties
-                    .printing_properties
-                    .window_orphan_lines
-                    .try_into()
-                    .unwrap_or_default(),
-            }
+                                  .printing_properties
+                                  .continuation_text_at_top,
+                                  table_properties
+                                  .printing_properties
+                                  .continuation_text_at_bottom],
+            continuation: {
+                let text = table_properties.printing_properties.continuation_text;
+                if text.is_empty() {
+                    None
+                } else {
+                    Some(text)
+                }
+            },
+            n_orphan_lines: table_properties
+                .printing_properties
+                .window_orphan_lines
+                .try_into()
+                .unwrap_or_default(),
+        }
     }
 }
 
@@ -121,9 +114,9 @@ impl Default for GeneralProperties {
     fn default() -> Self {
         Self {
             hide_empty_rows: true,
-            maximum_column_width: 120,
-            minimum_column_width: 36,
-            maximum_row_width: 72,
+            maximum_column_width: 72,
+            minimum_column_width: 50,
+            maximum_row_width: 120,
             minimum_row_width: 36,
             row_label_position: LabelPosition::Corner,
         }
@@ -153,6 +146,21 @@ struct CellFormatProperties {
     title: CellStyleHolder,
 }
 
+impl CellFormatProperties {
+    fn get_style(&self, area: Area) -> &CellStyle {
+        match area {
+            Area::Title => &self.title.style,
+            Area::Caption => &self.caption.style,
+            Area::Footer => &self.footnotes.style,
+            Area::Corner => &self.corner_labels.style,
+            Area::Labels(Axis2::X) => &self.column_labels.style,
+            Area::Labels(Axis2::Y) => &self.row_labels.style,
+            Area::Data(_) => &self.data.style,
+            Area::Layers => &self.layers.style,
+        }
+    }
+}
+
 #[derive(Clone, Debug, Deserialize)]
 #[serde(rename_all = "camelCase")]
 struct CellStyleHolder {
@@ -171,74 +179,140 @@ struct CellStyle {
     #[serde(rename = "@color2")]
     color2: Option<Color>,
     #[serde(rename = "@font-family")]
-    font_family: String,
+    font_family: Option<String>,
     #[serde(rename = "@font-size")]
-    font_size: Length,
+    font_size: Option<Length>,
     #[serde(rename = "@font-style")]
-    font_style: FontStyle,
+    font_style: Option<FontStyle>,
     #[serde(rename = "@font-weight")]
-    font_weight: FontWeight,
+    font_weight: Option<FontWeight>,
     #[serde(rename = "@font-underline")]
-    font_underline: FontUnderline,
+    font_underline: Option<FontUnderline>,
     #[serde(rename = "@labelLocationVertical")]
-    label_location_vertical: LabelLocationVertical,
+    label_location_vertical: Option<LabelLocationVertical>,
     #[serde(rename = "@margin-bottom")]
-    margin_bottom: Length,
+    margin_bottom: Option<Length>,
     #[serde(rename = "@margin-left")]
-    margin_left: Length,
+    margin_left: Option<Length>,
     #[serde(rename = "@margin-right")]
-    margin_right: Length,
+    margin_right: Option<Length>,
     #[serde(rename = "@margin-top")]
-    margin_top: Length,
-    #[serde(rename = "@textAlignment", default)]
-    text_alignment: TextAlignment,
+    margin_top: Option<Length>,
+    #[serde(rename = "@textAlignment")]
+    text_alignment: Option<TextAlignment>,
     #[serde(rename = "@decimal-offset")]
-    decimal_offset: Length,
+    decimal_offset: Option<Length>,
 }
 
 impl CellStyle {
-    fn as_area_style(&self, data_row: RowParity) -> AreaStyle {
+    fn default_area_style(area: Area) -> AreaStyle {
+        use HorzAlign::*;
+        use VertAlign::*;
+        const BLACKISH: Color = Color::new(0x01, 0x02, 0x05);
+        const WHITE: Color = Color::WHITE;
+        const DARK_BLUE: Color = Color::new(0x26, 0x4a, 0x60);
+        const LIGHT_GRAY: Color = Color::new(0xe0, 0xe0, 0xe0);
+        const VERY_LIGHT_GRAY: Color = Color::new(0xf9, 0xf9, 0xfb);
+        let (horz_align, vert_align, hmargins, vmargins, fg, bg, size) = match area {
+            Area::Title => (Some(Center), Middle, [6, 8], [1, 6], BLACKISH, WHITE, 11),
+            Area::Caption => (Some(Left), Top, [6, 8], [1, 1], BLACKISH, WHITE, 9),
+            Area::Footer => (Some(Left), Top, [18, 18], [2, 3], BLACKISH, WHITE, 9),
+            Area::Corner => (Some(Left), Bottom, [6, 8], [3, 1], DARK_BLUE, WHITE, 9),
+            Area::Labels(Axis2::X) => (Some(Center), Bottom, [6, 8], [2, 2], DARK_BLUE, WHITE, 9),
+            Area::Labels(Axis2::Y) => (Some(Left), Top, [6, 8], [3, 2], DARK_BLUE, LIGHT_GRAY, 9),
+            Area::Data(_) => (None, Top, [6, 8], [3, 2], DARK_BLUE, VERY_LIGHT_GRAY, 9),
+            Area::Layers => (Some(Left), Bottom, [6, 8], [1, 3], BLACKISH, WHITE, 9),
+        };
+        let bold = area == Area::Title;
         AreaStyle {
             cell_style: look::CellStyle {
-                horz_align: match self.text_alignment {
-                    TextAlignment::Left => Some(HorzAlign::Left),
-                    TextAlignment::Right => Some(HorzAlign::Right),
-                    TextAlignment::Center => Some(HorzAlign::Center),
-                    TextAlignment::Decimal => Some(HorzAlign::Decimal {
-                        offset: self.decimal_offset.as_px_f64(),
-                    }),
-                    TextAlignment::Mixed => None,
-                },
-                vert_align: match self.label_location_vertical {
-                    LabelLocationVertical::Positive => VertAlign::Top,
-                    LabelLocationVertical::Negative => VertAlign::Bottom,
-                    LabelLocationVertical::Center => VertAlign::Middle,
-                },
+                horz_align,
+                vert_align,
                 margins: enum_map! {
-                    Axis2::X => [self.margin_left.as_px_i32(), self.margin_right.as_px_i32()],
-                    Axis2::Y => [self.margin_top.as_px_i32(), self.margin_bottom.as_px_i32()],
+                    Axis2::X => hmargins,
+                    Axis2::Y => vmargins,
                 },
             },
             font_style: look::FontStyle {
-                bold: self.font_weight == FontWeight::Bold,
-                italic: self.font_style == FontStyle::Italic,
-                underline: self.font_underline == FontUnderline::Underline,
-                font: self.font_family.clone(),
-                fg: match data_row {
-                    RowParity::Even => self.color.unwrap_or(Color::BLACK),
-                    RowParity::Odd => self.alternating_text_color.unwrap_or(Color::BLACK),
-                },
-                bg: match data_row {
-                    RowParity::Even => self.color2.unwrap_or(Color::WHITE),
-                    RowParity::Odd => self.alternating_color.unwrap_or(Color::WHITE),
-                },
-                size: self.font_size.as_pt_i32(),
+                bold,
+                italic: false,
+                underline: false,
+                font: String::from("SansSerif"),
+                fg,
+                bg,
+                size,
             },
         }
     }
+
+    fn as_area_style(&self, area: Area) -> AreaStyle {
+        let mut style = Self::default_area_style(area);
+
+        if let Some(text_alignment) = self.text_alignment {
+            style.cell_style.horz_align = match text_alignment {
+                TextAlignment::Left => Some(HorzAlign::Left),
+                TextAlignment::Right => Some(HorzAlign::Right),
+                TextAlignment::Center => Some(HorzAlign::Center),
+                TextAlignment::Decimal => Some(HorzAlign::Decimal {
+                    offset: self.decimal_offset.map_or(0.0, |offset| offset.as_px_f64()),
+                }),
+                TextAlignment::Mixed => None,
+            };
+        }
+
+        if let Some(label_location_vertical) = self.label_location_vertical {
+            style.cell_style.vert_align = match label_location_vertical {
+                LabelLocationVertical::Positive => VertAlign::Top,
+                LabelLocationVertical::Negative => VertAlign::Bottom,
+                LabelLocationVertical::Center => VertAlign::Middle,
+            };
+        }
+
+        if let Some(margin_left) = self.margin_left {
+            style.cell_style.margins[Axis2::X][0] = margin_left.as_px_i32();
+        }
+        if let Some(margin_right) = self.margin_right {
+            style.cell_style.margins[Axis2::X][1] = margin_right.as_px_i32();
+        }
+        if let Some(margin_top) = self.margin_top {
+            style.cell_style.margins[Axis2::Y][0] = margin_top.as_px_i32();
+        }
+        if let Some(margin_bottom) = self.margin_bottom {
+            style.cell_style.margins[Axis2::Y][1] = margin_bottom.as_px_i32();
+        }
+
+        if let Some(font_weight) = self.font_weight {
+            style.font_style.bold = font_weight == FontWeight::Bold;
+        }
+        if let Some(font_style) = self.font_style {
+            style.font_style.italic = font_style == FontStyle::Italic;
+        }
+        if let Some(font_underline) = self.font_underline {
+            style.font_style.underline = font_underline == FontUnderline::Underline;
+        }
+        if let Some(font_family) = &self.font_family {
+            style.font_style.font = font_family.clone();
+        }
+
+        let (fg, bg) = if area.row_parity() == Some(RowParity::Odd)
+            && (self.alternating_text_color.is_some() || self.alternating_color.is_some())
+        {
+            (self.alternating_text_color, self.alternating_color)
+        } else {
+            (self.color, self.color2)
+        };
+        if fg.is_some() || bg.is_some() {
+            style.font_style.fg = fg.unwrap_or(Color::BLACK);
+            style.font_style.bg = bg.unwrap_or(Color::WHITE);
+        };
+        if let Some(font_size) = self.font_size {
+            style.font_style.size = font_size.as_pt_i32();
+        }
+        style
+    }
 }
 
-#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Deserialize)]
 #[serde(rename_all = "camelCase")]
 enum FontStyle {
     #[default]
@@ -246,7 +320,7 @@ enum FontStyle {
     Italic,
 }
 
-#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Deserialize)]
 #[serde(rename_all = "camelCase")]
 enum FontWeight {
     #[default]
@@ -254,7 +328,7 @@ enum FontWeight {
     Bold,
 }
 
-#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Deserialize)]
 #[serde(rename_all = "camelCase")]
 enum FontUnderline {
     #[default]
@@ -262,7 +336,7 @@ enum FontUnderline {
     Underline,
 }
 
-#[derive(Clone, Debug, Default, Deserialize)]
+#[derive(Copy, Clone, Debug, Default, Deserialize)]
 #[serde(rename_all = "camelCase")]
 enum TextAlignment {
     Left,
@@ -273,7 +347,7 @@ enum TextAlignment {
     Mixed,
 }
 
-#[derive(Clone, Debug, Default, Deserialize)]
+#[derive(Copy, Clone, Debug, Default, Deserialize)]
 #[serde(rename_all = "camelCase")]
 enum LabelLocationVertical {
     /// Top.
@@ -354,7 +428,7 @@ impl BorderProperties {
 
     fn decode(&self) -> EnumMap<Border, look::BorderStyle> {
         EnumMap::from_fn(|border: Border| {
-            let mut base = border.default_border_style();
+            let mut base = Self::default_border_style(border);
             if let Some(style) = self.get_style(border) {
                 if let Some(stroke) = style.stroke {
                     base.stroke = stroke;
@@ -366,6 +440,30 @@ impl BorderProperties {
             base
         })
     }
+
+    /// Returns the default border style for `border` when reading an XML
+    /// [Look].  (This is different from the system default.)
+    pub fn default_border_style(border: Border) -> look::BorderStyle {
+        const VERY_DARK_BLUE: Color = Color::new(0x15, 0x29, 0x35);
+        const LIGHT_GRAY: Color = Color::new(0xe0, 0xe0, 0xe0);
+        const GRAY: Color = Color::new(0xae, 0xae, 0xae);
+
+        match border {
+            Border::InnerFrame(_) | Border::OuterFrame(_) | Border::Title => {
+                look::BorderStyle::none().with_color(VERY_DARK_BLUE)
+            }
+            Border::Dimension(_) => look::BorderStyle::none().with_color(GRAY),
+            Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)) => {
+                look::BorderStyle::solid().with_color(GRAY)
+            }
+            Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::Y)) => {
+                look::BorderStyle::solid().with_color(LIGHT_GRAY)
+            }
+            Border::Category(_) => look::BorderStyle::none().with_color(GRAY),
+            Border::DataLeft => look::BorderStyle::none().with_color(VERY_DARK_BLUE),
+            Border::DataTop => look::BorderStyle::solid().with_color(VERY_DARK_BLUE),
+        }
+    }
 }
 
 #[derive(Clone, Debug, Deserialize, Default)]
@@ -380,7 +478,7 @@ struct BorderStyle {
     pub color: Option<Color>,
 }
 
-#[derive(Clone, Debug, Default, Deserialize)]
+#[derive(Clone, Debug, Deserialize)]
 #[serde(rename_all = "camelCase", default)]
 struct PrintingProperties {
     #[serde(rename = "@printAllLayers")]
@@ -408,6 +506,21 @@ struct PrintingProperties {
     continuation_text_at_top: bool,
 }
 
+impl Default for PrintingProperties {
+    fn default() -> Self {
+        Self {
+            print_all_layers: false,
+            print_each_layer_on_separate_page: false,
+            rescale_wide_table_to_fit_page: false,
+            rescale_long_table_to_fit_page: false,
+            window_orphan_lines: 2,
+            continuation_text: String::from("(cont.)"),
+            continuation_text_at_bottom: false,
+            continuation_text_at_top: false,
+        }
+    }
+}
+
 /// A length.
 #[derive(Copy, Clone, Default, PartialEq)]
 pub struct Length(
@@ -1025,7 +1138,7 @@ mod tests {
             paginate_layers: false,
             shrink_to_fit: EnumMap::from_fn(|_| false),
             show_continuations: [false, false],
-            continuation: None,
+            continuation: Some(String::from("(cont.)")),
             n_orphan_lines: 5,
         };
         assert_eq!(&look, &expected);