Add tests for background colors.
authorBen Pfaff <blp@cs.stanford.edu>
Mon, 5 Jan 2026 17:34:39 +0000 (09:34 -0800)
committerBen Pfaff <blp@cs.stanford.edu>
Mon, 5 Jan 2026 17:34:39 +0000 (09:34 -0800)
rust/doc/src/invoking/output.md
rust/pspp/src/output/drivers/text.rs
rust/pspp/src/output/drivers/text/text_line.rs
rust/pspp/src/spv/read/tests.rs
rust/pspp/src/spv/testdata/legacy17.expected [new file with mode: 0644]
rust/pspp/src/spv/testdata/legacy17.spv [new file with mode: 0644]

index 920ffec573b7f4f3fd94e9b99ecbad4319a05c30..3b8aaa4819252395dfc8020ceaeb08aa252ee59f 100644 (file)
@@ -37,10 +37,15 @@ This driver has the following options:
   Unicode boxes are generally more attractive but they can be harder
   to work with in some environments.  The default is `unicode`.
 
-* `emphasis = <bool>`  
-  If this is set to true, then the output includes bold and underline
-  emphasis with overstriking.  This is supported by only some
-  software, mainly on Unix.  The default is `false`.
+* `emphasis = "ansi"`  
+  `emphasis = "overstrike"`  
+  By default, text output is plain, without support for emphasis.  Set
+  this to `ansi` to enable bold, italic, underline, and colors in
+  terminals that support it using [ANSI escape codes], or to
+  `overstrike` to enable bold and underline for emphasis with
+  overstriking.
+
+  [ANSI escape codes]: https://en.wikipedia.org/wiki/ANSI_escape_code
 
 # PDF Output (`.pdf`)
 
index 6f5fa1601ea05529937c5ab81a4f649613d99aa9..855a87c94043a89777e0939bfa43d2bacdfbbc43 100644 (file)
@@ -74,11 +74,23 @@ pub struct TextConfig {
     options: TextRendererOptions,
 }
 
+/// How to enable emphasis in output.
+#[derive(Copy, Clone, Debug, Default, Deserialize, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Emphasis {
+    /// Use ANSI codes for full-color output.
+    #[default]
+    Ansi,
+
+    /// Use backspaces and overstriking for bold and underline.
+    Overstrike,
+}
+
 #[derive(Clone, Debug, Default, Deserialize, Serialize)]
 #[serde(default)]
 pub struct TextRendererOptions {
-    /// Enable bold and underline in output?
-    pub emphasis: bool,
+    /// Enable emphasis in output?
+    pub emphasis: Option<Emphasis>,
 
     /// Page width.
     pub width: Option<usize>,
@@ -89,7 +101,7 @@ pub struct TextRendererOptions {
 
 pub struct TextRenderer {
     /// Enable bold and underline in output?
-    emphasis: bool,
+    emphasis: Option<Emphasis>,
 
     /// Page width.
     width: isize,
@@ -111,6 +123,10 @@ impl Default for TextRenderer {
 }
 
 impl TextRenderer {
+    pub fn with_emphasis(self, emphasis: Option<Emphasis>) -> Self {
+        Self { emphasis, ..self }
+    }
+
     pub fn new(config: &TextRendererOptions) -> Self {
         let width = config.width.unwrap_or(usize::MAX).min(isize::MAX as usize) as isize;
         Self {
@@ -398,7 +414,7 @@ impl TextRenderer {
         Ok(())
     }
 
-    fn render<W>(&mut self, item: &Item, writer: &mut W) -> FmtResult
+    pub fn render<W>(&mut self, item: &Item, writer: &mut W) -> FmtResult
     where
         W: FmtWrite,
     {
@@ -437,8 +453,28 @@ impl TextRenderer {
             let mut pager = Pager::new(self, table, Some(layer_indexes.as_slice()));
             while pager.has_next(self).is_some() {
                 pager.draw_next(self, isize::MAX);
-                for line in self.lines.drain(..) {
-                    writeln!(writer, "{line}")?;
+                match self.emphasis {
+                    Some(Emphasis::Ansi) => {
+                        let width = self
+                            .lines
+                            .iter()
+                            .map(|line| line.width())
+                            .max()
+                            .unwrap_or_default();
+                        for line in self.lines.drain(..) {
+                            writeln!(writer, "{}", line.display_sgr().with_width(width))?;
+                        }
+                    }
+                    Some(Emphasis::Overstrike) => {
+                        for line in self.lines.drain(..) {
+                            writeln!(writer, "{}", line.display_overstrike())?;
+                        }
+                    }
+                    None => {
+                        for line in self.lines.drain(..) {
+                            writeln!(writer, "{line}")?;
+                        }
+                    }
                 }
             }
         }
@@ -664,7 +700,10 @@ impl Device for TextRenderer {
                 continue;
             };
 
-            let attribute = self.emphasis.then(|| Attribute::for_style(cell.font_style));
+            let attribute = self
+                .emphasis
+                .is_some()
+                .then(|| Attribute::for_style(cell.font_style));
             self.get_line(y as usize).put(x as usize, &text, attribute);
         }
     }
index e4f992c92576b1c10e17f6adfb4729f917d6083c..9d1019c465bc39c2d71a7f9ce9ce2dec3ad3dc07 100644 (file)
@@ -26,20 +26,28 @@ use unicode_width::UnicodeWidthChar;
 
 use crate::output::pivot::look::{Color, FontStyle};
 
-#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
 pub struct Attribute {
-    pub fg: Option<Color>,
-    pub bg: Option<Color>,
+    pub fg: Color,
+    pub bg: Color,
     pub bold: bool,
     pub italic: bool,
     pub underline: bool,
 }
 
-impl Attribute {
-    pub fn is_empty(&self) -> bool {
-        self.fg.is_none() && self.bg.is_none() && !self.bold && !self.italic && !self.underline
+impl Default for Attribute {
+    fn default() -> Self {
+        Self {
+            fg: Color::BLACK,
+            bg: Color::WHITE,
+            bold: false,
+            italic: false,
+            underline: false,
+        }
     }
+}
 
+impl Attribute {
     pub fn affixes(&self) -> (&'static str, &'static str) {
         match (self.bold, self.italic, self.underline) {
             (false, false, false) => ("", ""),
@@ -53,6 +61,14 @@ impl Attribute {
         }
     }
 
+    pub fn overstrike<'a>(&self, content: &'a str) -> Overstrike<'a> {
+        Overstrike {
+            bold: self.bold,
+            underline: self.underline,
+            content,
+        }
+    }
+
     pub fn sgr<T>(&self, content: T) -> Sgr<'_, T> {
         Sgr {
             attribute: self,
@@ -62,8 +78,8 @@ impl Attribute {
 
     pub fn for_style(font_style: &FontStyle) -> Self {
         Self {
-            fg: Some(font_style.fg),
-            bg: Some(font_style.bg),
+            fg: font_style.fg,
+            bg: font_style.bg,
             bold: font_style.bold,
             italic: font_style.italic,
             underline: font_style.underline,
@@ -71,6 +87,40 @@ impl Attribute {
     }
 }
 
+pub struct Overstrike<'a> {
+    bold: bool,
+    underline: bool,
+    content: &'a str,
+}
+
+impl<'a> Display for Overstrike<'a> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        if !self.bold && !self.underline {
+            return f.write_str(self.content);
+        }
+        for c in self.content.chars() {
+            f.write_char(c)?;
+            if let Some(width) = c.width() {
+                if self.bold {
+                    for _ in 0..width {
+                        f.write_char('\x08')?;
+                    }
+                    f.write_char(c)?;
+                }
+                if self.underline {
+                    for _ in 0..width {
+                        f.write_char('\x08')?;
+                    }
+                    for _ in 0..width {
+                        f.write_char('_')?;
+                    }
+                }
+            }
+        }
+        Ok(())
+    }
+}
+
 pub struct Sgr<'a, T> {
     attribute: &'a Attribute,
     content: T,
@@ -82,12 +132,12 @@ where
 {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         let mut s = SmallString::<[u8; 32]>::new();
-        if let Some(fg) = self.attribute.fg {
-            write!(&mut s, "38;2;{};{};{};", fg.r, fg.g, fg.b).unwrap();
-        }
-        if let Some(bg) = self.attribute.bg {
-            write!(&mut s, "48;2;{};{};{};", bg.r, bg.g, bg.b).unwrap();
-        }
+
+        let (r, g, b) = self.attribute.fg.into_rgb();
+        write!(&mut s, "38;2;{r};{g};{b};").unwrap();
+
+        let (r, g, b) = self.attribute.bg.into_rgb();
+        write!(&mut s, "48;2;{r};{g};{b};").unwrap();
         if self.attribute.bold {
             write!(&mut s, "1;").unwrap();
         }
@@ -97,13 +147,8 @@ where
         if self.attribute.underline {
             write!(&mut s, "4;").unwrap();
         }
-        if !s.is_empty() {
-            s.pop();
-            write!(f, "\x1b[{s}m{}\x1b[0m", &self.content)?;
-        } else {
-            write!(f, "{}", &self.content)?;
-        }
-        Ok(())
+        s.pop();
+        write!(f, "\x1b[{s}m{}\x1b[0m", &self.content)
     }
 }
 
@@ -245,8 +290,15 @@ impl TextLine {
         &self.string
     }
 
+    pub fn display_overstrike(&self) -> DisplayOverstrike<'_> {
+        DisplayOverstrike(self)
+    }
+
     pub fn display_sgr(&self) -> DisplaySgr<'_> {
-        DisplaySgr(self)
+        DisplaySgr {
+            line: self,
+            width: 0,
+        }
     }
 
     pub fn display_wiki(&self) -> DisplayWiki<'_> {
@@ -262,6 +314,10 @@ impl TextLine {
             x: 0,
         }
     }
+
+    pub fn width(&self) -> usize {
+        self.width
+    }
 }
 
 impl Display for TextLine {
@@ -286,16 +342,50 @@ impl<'a> Display for DisplayWiki<'a> {
     }
 }
 
-pub struct DisplaySgr<'a>(&'a TextLine);
+pub struct DisplayOverstrike<'a>(&'a TextLine);
 
-impl<'a> Display for DisplaySgr<'a> {
+impl<'a> Display for DisplayOverstrike<'a> {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let default = Attribute::default();
         for (s, attribute) in self.0.iter() {
-            if let Some(attribute) = attribute {
-                write!(f, "{}", attribute.sgr(s))?;
-            } else {
-                f.write_str(s)?;
+            write!(
+                f,
+                "{}",
+                attribute.as_ref().unwrap_or(&default).overstrike(s)
+            )?;
+        }
+        Ok(())
+    }
+}
+
+pub struct DisplaySgr<'a> {
+    line: &'a TextLine,
+    width: usize,
+}
+
+impl<'a> DisplaySgr<'a> {
+    pub fn with_width(self, width: usize) -> Self {
+        Self { width, ..self }
+    }
+}
+
+impl<'a> Display for DisplaySgr<'a> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let default = Attribute::default();
+        for (s, attribute) in self.line.iter() {
+            write!(f, "{}", attribute.as_ref().unwrap_or(&default).sgr(s))?;
+        }
+        if let Some(pad) = self.width.checked_sub(self.line.width) {
+            struct Spaces(usize);
+            impl Display for Spaces {
+                fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+                    for _ in 0..self.0 {
+                        f.write_char(' ')?;
+                    }
+                    Ok(())
+                }
             }
+            write!(f, "{}", default.sgr(Spaces(pad)))?;
         }
         Ok(())
     }
index 65de04f68a79355149bf365548bbc7b403683807..a8ef1ac7756563d6441ed2b36cc58529b6dfb910 100644 (file)
@@ -5,28 +5,32 @@ use std::{
 };
 
 use crate::{
-    output::{Text, pivot::tests::assert_lines_eq},
+    output::{
+        Text,
+        drivers::text::{Emphasis, TextRenderer},
+        pivot::tests::assert_lines_eq,
+    },
     spv::SpvArchive,
 };
 
 #[test]
 fn legacy1() {
-    test_raw_spvfile("legacy1");
+    test_raw_spvfile("legacy1", None);
 }
 
 #[test]
 fn legacy2() {
-    test_raw_spvfile("legacy2");
+    test_raw_spvfile("legacy2", None);
 }
 
 #[test]
 fn legacy3() {
-    test_raw_spvfile("legacy3");
+    test_raw_spvfile("legacy3", None);
 }
 
 #[test]
 fn legacy4() {
-    test_raw_spvfile("legacy4");
+    test_raw_spvfile("legacy4", None);
 }
 
 /// Layer.
@@ -34,91 +38,97 @@ fn legacy4() {
 /// (But we need to support selecting a layer value, too.)
 #[test]
 fn legacy5() {
-    test_raw_spvfile("legacy5");
+    test_raw_spvfile("legacy5", None);
 }
 
 /// Layer, with a particular layer selected.
 #[test]
 fn legacy6() {
-    test_raw_spvfile("legacy6");
+    test_raw_spvfile("legacy6", None);
 }
 
 /// Regression test for `<setFormat reset="true">`.
 #[test]
 fn legacy7() {
-    test_raw_spvfile("legacy7");
+    test_raw_spvfile("legacy7", None);
 }
 
 /// Checks for `Dimension::hide_all_labels`.
 #[test]
 fn legacy8() {
-    test_raw_spvfile("legacy8");
+    test_raw_spvfile("legacy8", None);
 }
 
 /// Checks for caption defined as a footnote label, and for footnotes in layer
 /// values.
 #[test]
 fn legacy9() {
-    test_raw_spvfile("legacy9");
+    test_raw_spvfile("legacy9", None);
 }
 
 /// Checks for footnotes in dimension labels.
 #[test]
 fn legacy10() {
-    test_raw_spvfile("legacy10");
+    test_raw_spvfile("legacy10", None);
 }
 
 /// Checks for footnotes on data cells added via XML rather than `cellFootnotes`
 /// series.
 #[test]
 fn legacy11() {
-    test_raw_spvfile("legacy11");
+    test_raw_spvfile("legacy11", None);
 }
 
 /// Checks that footnotes on data cells added via the `cellFootnotes` series
 /// can't be deleted via `setFormat`.
 #[test]
 fn legacy12() {
-    test_raw_spvfile("legacy12");
+    test_raw_spvfile("legacy12", None);
 }
 
 /// Checks that we support multiple `<setFormat>` elements within a single
 /// `<setCellProperties>`.
 #[test]
 fn legacy13() {
-    test_raw_spvfile("legacy13");
+    test_raw_spvfile("legacy13", None);
 }
 
 /// Check for correct defaults in XML looks.
 #[test]
 fn legacy14() {
-    test_raw_spvfile("legacy14");
+    test_raw_spvfile("legacy14", None);
 }
 
 /// Checks that categories are ordered correctly when the first row has some
 /// missing cells (in this case, "Beta" lacks a value in the first row).
 #[test]
 fn legacy15() {
-    test_raw_spvfile("legacy15");
+    test_raw_spvfile("legacy15", None);
 }
 
 /// Subscript support.
 #[test]
 fn legacy16() {
-    test_raw_spvfile("legacy16");
+    test_raw_spvfile("legacy16", None);
 }
 
-fn test_raw_spvfile(name: &str) {
+/// Support styling cells with colored backgrounds.
+#[test]
+fn legacy17() {
+    test_raw_spvfile("legacy17", Some(Emphasis::Ansi));
+}
+
+fn test_raw_spvfile(name: &str, emphasis: Option<Emphasis>) {
     let input_filename = Path::new("src/spv/testdata")
         .join(name)
         .with_extension("spv");
     let spvfile = BufReader::new(File::open(&input_filename).unwrap());
     let expected_filename = input_filename.with_extension("expected");
     let expected = String::from_utf8(std::fs::read(&expected_filename).unwrap()).unwrap();
-    test_spvfile(spvfile, &expected, &expected_filename);
+    test_spvfile(spvfile, &expected, &expected_filename, emphasis);
 }
 
-fn test_spvfile<R>(spvfile: R, expected: &str, expected_filename: &Path)
+fn test_spvfile<R>(spvfile: R, expected: &str, expected_filename: &Path, emphasis: Option<Emphasis>)
 where
     R: BufRead + Seek + 'static,
 {
@@ -145,7 +155,9 @@ where
         Err(error) => Text::new_log(error.to_string()).into_item(),
     };
 
-    let actual = output.to_string();
+    let mut renderer = TextRenderer::default().with_emphasis(emphasis);
+    let mut actual = String::new();
+    renderer.render(&output, &mut actual).unwrap();
     if expected != actual {
         if std::env::var("PSPP_REFRESH_EXPECTED").is_ok() {
             std::fs::write(expected_filename, actual).unwrap();
diff --git a/rust/pspp/src/spv/testdata/legacy17.expected b/rust/pspp/src/spv/testdata/legacy17.expected
new file mode 100644 (file)
index 0000000..3aa1e77
--- /dev/null
@@ -0,0 +1,10 @@
+\e[38;2;0;0;0;48;2;255;255;255m                                   \e[0m\e[38;2;0;0;0;48;2;255;255;255;1mCoefficients[a]\e[0m\e[38;2;0;0;0;48;2;255;255;255m                                  \e[0m
+\e[38;2;0;0;0;48;2;255;255;255m╭───────────────┬────────────────────────────┬─────────────────────────┬──────┬────╮\e[0m\e[38;2;0;0;0;48;2;255;255;255m\e[0m
+\e[38;2;0;0;0;48;2;255;255;255m│               │ \e[0m\e[38;2;0;0;0;48;2;255;255;255mUnstandardized Coefficients\e[0m\e[38;2;0;0;0;48;2;255;255;255m│\e[0m\e[38;2;0;0;0;48;2;255;255;255mStandardized Coefficients\e[0m\e[38;2;0;0;0;48;2;255;255;255m│      │    │\e[0m\e[38;2;0;0;0;48;2;255;255;255m\e[0m
+\e[38;2;0;0;0;48;2;255;255;255m│               ├────────────┬───────────────┼─────────────────────────┤      │    │\e[0m\e[38;2;0;0;0;48;2;255;255;255m\e[0m
+\e[38;2;0;0;0;48;2;255;255;255m│\e[0m\e[38;2;0;0;0;48;2;255;255;255mModel\e[0m\e[38;2;0;0;0;48;2;255;255;255m          │      \e[0m\e[38;2;0;0;0;48;2;255;255;255mB\e[0m\e[38;2;0;0;0;48;2;255;255;255m     │   \e[0m\e[38;2;0;0;0;48;2;255;255;255mStd. Error\e[0m\e[38;2;0;0;0;48;2;255;255;255m  │           \e[0m\e[38;2;0;0;0;48;2;255;255;255mBeta\e[0m\e[38;2;0;0;0;48;2;255;255;255m          │   \e[0m\e[38;2;0;0;0;48;2;255;255;255mt\e[0m\e[38;2;0;0;0;48;2;255;255;255m  │\e[0m\e[38;2;0;0;0;48;2;255;255;255mSig.\e[0m\e[38;2;0;0;0;48;2;255;255;255m│\e[0m\e[38;2;0;0;0;48;2;255;255;255m\e[0m
+\e[38;2;0;0;0;48;2;255;255;255m├───────────────┼────────────┼───────────────┼─────────────────────────┼──────┼────┤\e[0m\e[38;2;0;0;0;48;2;255;255;255m\e[0m
+\e[38;2;0;0;0;48;2;255;255;255m│\e[0m\e[38;2;0;0;0;48;2;255;255;255m1.00\e[0m\e[38;2;0;0;0;48;2;255;255;255m \e[0m\e[38;2;0;0;0;48;2;255;255;255m(Constant)\e[0m\e[38;2;0;0;0;48;2;255;255;255m│      \e[0m\e[38;2;0;0;0;48;2;255;255;255m59.146\e[0m\e[38;2;0;0;0;48;2;255;255;255m│         \e[0m\e[38;2;0;0;0;48;2;255;255;255m18.854\e[0m\e[38;2;0;0;0;48;2;255;255;255m│                         │ \e[0m\e[38;2;0;0;0;48;2;239;51;56;1;3m3.137\e[0m\e[38;2;0;0;0;48;2;255;255;255m│\e[0m\e[38;2;0;0;0;48;2;255;255;255m.016\e[0m\e[38;2;0;0;0;48;2;255;255;255m│\e[0m\e[38;2;0;0;0;48;2;255;255;255m\e[0m
+\e[38;2;0;0;0;48;2;255;255;255m│     \e[0m\e[38;2;0;0;0;48;2;255;255;255mVariable A\e[0m\e[38;2;0;0;0;48;2;255;255;255m│       \e[0m\e[38;2;0;0;0;48;2;255;255;255m-.664\e[0m\e[38;2;0;0;0;48;2;255;255;255m│           \e[0m\e[38;2;0;0;0;48;2;255;255;255m.585\e[0m\e[38;2;0;0;0;48;2;255;255;255m│                    \e[0m\e[38;2;0;0;0;48;2;255;255;255m-.395\e[0m\e[38;2;0;0;0;48;2;255;255;255m│\e[0m\e[38;2;0;0;0;48;2;255;255;255m-1.136\e[0m\e[38;2;0;0;0;48;2;255;255;255m│\e[0m\e[38;2;0;0;0;48;2;255;255;255m.293\e[0m\e[38;2;0;0;0;48;2;255;255;255m│\e[0m\e[38;2;0;0;0;48;2;255;255;255m\e[0m
+\e[38;2;0;0;0;48;2;255;255;255m╰───────────────┴────────────┴───────────────┴─────────────────────────┴──────┴────╯\e[0m\e[38;2;0;0;0;48;2;255;255;255m\e[0m
+\e[38;2;0;0;0;48;2;255;255;255ma. Dependent Variable: A\e[0m\e[38;2;0;0;0;48;2;255;255;255m                                                            \e[0m
diff --git a/rust/pspp/src/spv/testdata/legacy17.spv b/rust/pspp/src/spv/testdata/legacy17.spv
new file mode 100644 (file)
index 0000000..b9af586
Binary files /dev/null and b/rust/pspp/src/spv/testdata/legacy17.spv differ