From 8dd54a6bb49b3a651e7431431779accc67eccc88 Mon Sep 17 00:00:00 2001 From: Ben Pfaff Date: Mon, 5 Jan 2026 09:34:39 -0800 Subject: [PATCH] Add tests for background colors. --- rust/doc/src/invoking/output.md | 13 +- rust/pspp/src/output/drivers/text.rs | 53 ++++++- .../pspp/src/output/drivers/text/text_line.rs | 146 ++++++++++++++---- rust/pspp/src/spv/read/tests.rs | 54 ++++--- rust/pspp/src/spv/testdata/legacy17.expected | 10 ++ rust/pspp/src/spv/testdata/legacy17.spv | Bin 0 -> 5047 bytes 6 files changed, 216 insertions(+), 60 deletions(-) create mode 100644 rust/pspp/src/spv/testdata/legacy17.expected create mode 100644 rust/pspp/src/spv/testdata/legacy17.spv diff --git a/rust/doc/src/invoking/output.md b/rust/doc/src/invoking/output.md index 920ffec573..3b8aaa4819 100644 --- a/rust/doc/src/invoking/output.md +++ b/rust/doc/src/invoking/output.md @@ -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 = ` - 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`) diff --git a/rust/pspp/src/output/drivers/text.rs b/rust/pspp/src/output/drivers/text.rs index 6f5fa1601e..855a87c940 100644 --- a/rust/pspp/src/output/drivers/text.rs +++ b/rust/pspp/src/output/drivers/text.rs @@ -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, /// Page width. pub width: Option, @@ -89,7 +101,7 @@ pub struct TextRendererOptions { pub struct TextRenderer { /// Enable bold and underline in output? - emphasis: bool, + emphasis: Option, /// Page width. width: isize, @@ -111,6 +123,10 @@ impl Default for TextRenderer { } impl TextRenderer { + pub fn with_emphasis(self, emphasis: Option) -> 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(&mut self, item: &Item, writer: &mut W) -> FmtResult + pub fn render(&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); } } diff --git a/rust/pspp/src/output/drivers/text/text_line.rs b/rust/pspp/src/output/drivers/text/text_line.rs index e4f992c925..9d1019c465 100644 --- a/rust/pspp/src/output/drivers/text/text_line.rs +++ b/rust/pspp/src/output/drivers/text/text_line.rs @@ -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, - pub bg: Option, + 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(&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(()) } diff --git a/rust/pspp/src/spv/read/tests.rs b/rust/pspp/src/spv/read/tests.rs index 65de04f68a..a8ef1ac775 100644 --- a/rust/pspp/src/spv/read/tests.rs +++ b/rust/pspp/src/spv/read/tests.rs @@ -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 ``. #[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 `` elements within a single /// ``. #[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) { 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(spvfile: R, expected: &str, expected_filename: &Path) +fn test_spvfile(spvfile: R, expected: &str, expected_filename: &Path, emphasis: Option) 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 index 0000000000..3aa1e77162 --- /dev/null +++ b/rust/pspp/src/spv/testdata/legacy17.expected @@ -0,0 +1,10 @@ + Coefficients[a]  +╭───────────────┬────────────────────────────┬─────────────────────────┬──────┬────╮ +│ │ Unstandardized Coefficients│Standardized Coefficients│ │ │ +│ ├────────────┬───────────────┼─────────────────────────┤ │ │ +│Model │ B │ Std. Error │ Beta │ t │Sig.│ +├───────────────┼────────────┼───────────────┼─────────────────────────┼──────┼────┤ +│1.00 (Constant)│ 59.146│ 18.854│ │ 3.137│.016│ +│ Variable A│ -.664│ .585│ -.395│-1.136│.293│ +╰───────────────┴────────────┴───────────────┴─────────────────────────┴──────┴────╯ +a. Dependent Variable: A  diff --git a/rust/pspp/src/spv/testdata/legacy17.spv b/rust/pspp/src/spv/testdata/legacy17.spv new file mode 100644 index 0000000000000000000000000000000000000000..b9af58643dfa7948bfefd3e0b9de5f6dc2291e57 GIT binary patch literal 5047 zcma)=XEdDq+Qx}CqIW^`&gfzWA$lDodf36}q8o!vh~8_|jEG)>=)FgXQHDhH3`QHG zcS1PH`{C^U?)9Ft-s@Ta^?bSi_gc^I%XK}Mfes!%Ee-$x!11-_w#50HgmLh3#QrTS zDJe0T2a;malE2=Pk}_bBw1gGR2I^|BY6G(ofjYb24CCO~&h`$a^rCuuk-fdWcRx}+ zvP{cPfAu}hEN$qKf~MXWvf$BGEzvx4WTTAUk#Dw-=z#ShkSkhi#}xZMa~bB};_K`+ zcS?L#Z^{z7xpkxB_#UXUcsA5Mc5`^gJsNR!j?R+_*E*lP;hJ}|S^Ok2S7x_Sx=CHC>lXxTA{-op|A>bF*Tlcci2Y|YBK~f!Qx+D< z3++_F`!xo}v_rViZTGpYJ=Ws3M%*-?I9c!}qA$Zql-XQDQ#SDO)KT*f=f?~S9SCkw zq11lN0V25wGtqW&>W*Ms60_&l5rsIqAPDt}gn73%zFZ$&{#Y!1-NM0s>b)TN~6;LnI zYUc@gLoUFV;)0wf@Bo^wMR^(DzRv*Wdv$gSsc>;cW#m66g}$w4{D$LkJ{WiSzdRy604XHODmPvT|!P+KPv)_RLEQ@k#E z8MW`fXxZ-@{t6x4+AJqiL`{pa7Ma)?xEeq}8_m9l#=MF*A_@5^)$JwsV)g7|@F7y> z{h*8vIRX*urnm^+Gs7Bm7G!IMsA|(HoJ@V7+hld|c7Z^DiWeT)iM$)i^HfMfa&ac{oH?Eyk=}dF*;+$=4N*! zXN5i-HyU1+<#z;6wsPV{Y&CeiBpW-E3yg

Al1t9} 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 F=U;!Y71jU% literal 0 HcmV?d00001 -- 2.30.2