From 1150f9d052f91e5cbc53ee0cfd21092911d7a815 Mon Sep 17 00:00:00 2001 From: Ben Pfaff Date: Sun, 4 Jan 2026 20:00:23 -0800 Subject: [PATCH] work on colored text output (for testing spv output) --- rust/Cargo.lock | 22 + rust/pspp/Cargo.toml | 1 + rust/pspp/src/output/drivers/text.rs | 16 +- .../pspp/src/output/drivers/text/text_line.rs | 649 ++++++++++++------ rust/pspp/src/output/pivot.rs | 2 + rust/pspp/src/spv/read.rs | 1 + rust/pspp/src/spv/read/legacy_xml.rs | 357 ++++++---- 7 files changed, 694 insertions(+), 354 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 48a5a75043..5257a627bb 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -254,6 +254,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "btree_monstrousity" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec92912346b936c974181a172d9abc81f50d41e40118fc101dac8aa8134bee3" +dependencies = [ + "cfg-if", + "rustversion", +] + [[package]] name = "bumpalo" version = "3.17.0" @@ -1493,6 +1503,17 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "nodit" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1c69fcda6eef9aedb71d806e84764dd6f73df06ef60a79a35b6e377f9e6456" +dependencies = [ + "btree_monstrousity", + "itertools 0.13.0", + "smallvec", +] + [[package]] name = "nom" version = "7.1.3" @@ -1900,6 +1921,7 @@ dependencies = [ "libc", "libm", "ndarray", + "nodit", "num", "ordered-float", "pango", diff --git a/rust/pspp/Cargo.toml b/rust/pspp/Cargo.toml index 4190097282..6bda045b35 100644 --- a/rust/pspp/Cargo.toml +++ b/rust/pspp/Cargo.toml @@ -58,6 +58,7 @@ paper-sizes = { path = "../paper-sizes", features = ["serde"] } enumset = "1.1.10" bit-vec = "0.8.0" erased-serde = "0.4.9" +nodit = "0.10.0" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.48.0", features = ["Win32_Globalization"] } diff --git a/rust/pspp/src/output/drivers/text.rs b/rust/pspp/src/output/drivers/text.rs index a9fc41917d..6f5fa1601e 100644 --- a/rust/pspp/src/output/drivers/text.rs +++ b/rust/pspp/src/output/drivers/text.rs @@ -29,7 +29,9 @@ use serde::{Deserialize, Serialize}; use unicode_linebreak::{BreakOpportunity, linebreaks}; use unicode_width::UnicodeWidthStr; -use crate::output::{Itemlike, render::Extreme, table::DrawCell}; +use crate::output::{ + Itemlike, drivers::text::text_line::Attribute, render::Extreme, table::DrawCell, +}; use crate::output::{ Details, Item, @@ -43,7 +45,7 @@ use crate::output::{ }; mod text_line; -use text_line::{Emphasis, TextLine, clip_text}; +use text_line::{TextLine, clip_text}; #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] @@ -629,7 +631,7 @@ impl Device for TextRenderer { let c = self.box_chars[lines]; for y in y { self.get_line(y as usize) - .put_multiple(x.start as usize, c, x.len()); + .put_multiple(x.start as usize, c, x.len(), None); } } @@ -662,12 +664,8 @@ impl Device for TextRenderer { continue; }; - let text = if self.emphasis { - Emphasis::from(cell.font_style).apply(text) - } else { - Cow::from(text) - }; - self.get_line(y as usize).put(x as usize, &text); + let attribute = self.emphasis.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 bd31c5f932..e4f992c925 100644 --- a/rust/pspp/src/output/drivers/text/text_line.rs +++ b/rust/pspp/src/output/drivers/text/text_line.rs @@ -14,28 +14,112 @@ // You should have received a copy of the GNU General Public License along with // this program. If not, see . -use enum_iterator::Sequence; +use nodit::{Interval, NoditMap, interval::ie}; +use smallstr::SmallString; use std::{ - borrow::Cow, cmp::Ordering, - fmt::{Debug, Display}, + fmt::{Debug, Display, Write as _}, ops::Range, }; use unicode_width::UnicodeWidthChar; -use crate::output::pivot::look::FontStyle; +use crate::output::pivot::look::{Color, FontStyle}; + +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub struct Attribute { + pub fg: Option, + pub bg: Option, + 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 + } + + pub fn affixes(&self) -> (&'static str, &'static str) { + match (self.bold, self.italic, self.underline) { + (false, false, false) => ("", ""), + (false, false, true) => ("_", "_"), + (false, true, false) => ("/", "/"), + (false, true, true) => ("_/", "/_"), + (true, false, false) => ("*", "*"), + (true, true, false) => ("*/", "/*"), + (true, false, true) => ("_*", "*_"), + (true, true, true) => ("_/*", "*/_"), + } + } + + pub fn sgr(&self, content: T) -> Sgr<'_, T> { + Sgr { + attribute: self, + content, + } + } + + pub fn for_style(font_style: &FontStyle) -> Self { + Self { + fg: Some(font_style.fg), + bg: Some(font_style.bg), + bold: font_style.bold, + italic: font_style.italic, + underline: font_style.underline, + } + } +} + +pub struct Sgr<'a, T> { + attribute: &'a Attribute, + content: T, +} + +impl<'a, T> Display for Sgr<'a, T> +where + T: Display, +{ + 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(); + } + if self.attribute.bold { + write!(&mut s, "1;").unwrap(); + } + if self.attribute.italic { + write!(&mut s, "3;").unwrap(); + } + 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(()) + } +} /// A line of text, encoded in UTF-8, with support functions that properly /// handle double-width characters and backspaces. /// /// Designed to make appending text fast, and access and modification of other /// column positions possible. -#[derive(Clone, Default)] +#[derive(Clone, Default, Debug)] pub struct TextLine { /// Content. string: String, + /// Attributes. + attributes: NoditMap, Attribute>, + /// Display width, in character positions. width: usize, } @@ -68,7 +152,7 @@ impl TextLine { self.width = x; } - fn put_closure(&mut self, x0: usize, w: usize, push_str: F) + fn put_closure(&mut self, x0: usize, w: usize, push_str: F, attribute: Option) where F: FnOnce(&mut String), { @@ -98,18 +182,30 @@ impl TextLine { self.string.extend((x1..span.columns.end).map(|_| '?')); self.string.push_str(&tail); } + if w > 0 { + let interval = ie(x0, x1); + let _ = self.attributes.cut(&interval); + if let Some(attribute) = attribute { + self.attributes + .insert_merge_touching_if_values_equal(interval, attribute) + .expect("interval was cut"); + } + } } - pub fn put(&mut self, x0: usize, s: &str) { + pub fn put(&mut self, x0: usize, s: &str, attribute: Option) { self.string.reserve(s.len()); - self.put_closure(x0, Widths::new(s).sum(), |dst| dst.push_str(s)); + self.put_closure(x0, Widths::new(s).sum(), |dst| dst.push_str(s), attribute); } - pub fn put_multiple(&mut self, x0: usize, c: char, n: usize) { + pub fn put_multiple(&mut self, x0: usize, c: char, n: usize, attribute: Option) { self.string.reserve(c.len_utf8() * n); - self.put_closure(x0, c.width().unwrap() * n, |dst| { - (0..n).for_each(|_| dst.push(c)) - }); + self.put_closure( + x0, + c.width().unwrap() * n, + |dst| (0..n).for_each(|_| dst.push(c)), + attribute, + ); } fn find_span(&self, x0: usize, x1: usize) -> Position { @@ -148,6 +244,24 @@ impl TextLine { pub fn str(&self) -> &str { &self.string } + + pub fn display_sgr(&self) -> DisplaySgr<'_> { + DisplaySgr(self) + } + + pub fn display_wiki(&self) -> DisplayWiki<'_> { + DisplayWiki(self) + } + + fn iter(&self) -> impl Iterator)> { + TextIter { + next: None, + line: self, + attr: self.attributes.iter(), + ofs: 0, + x: 0, + } + } } impl Display for TextLine { @@ -156,6 +270,107 @@ impl Display for TextLine { } } +pub struct DisplayWiki<'a>(&'a TextLine); + +impl<'a> Display for DisplayWiki<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (s, attribute) in self.0.iter() { + let (prefix, suffix) = if let Some(attribute) = attribute { + attribute.affixes() + } else { + ("", "") + }; + write!(f, "{prefix}{s}{suffix}")?; + } + Ok(()) + } +} + +pub struct DisplaySgr<'a>(&'a TextLine); + +impl<'a> Display for DisplaySgr<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (s, attribute) in self.0.iter() { + if let Some(attribute) = attribute { + write!(f, "{}", attribute.sgr(s))?; + } else { + f.write_str(s)?; + } + } + Ok(()) + } +} + +struct TextIter<'a, I> +where + I: DoubleEndedIterator, &'a Attribute)>, +{ + line: &'a TextLine, + next: Option<(&'a str, Option)>, + attr: I, + ofs: usize, + x: usize, +} + +impl<'a, I> TextIter<'a, I> +where + I: DoubleEndedIterator, &'a Attribute)>, +{ + fn take_up_to(&mut self, column: usize) -> &'a str { + let mut x = self.x; + for (index, c) in self.line.string[self.ofs..].char_indices() { + let w = c.width().unwrap_or_default(); + if x + w > column { + let result = &self.line.string[self.ofs..self.ofs + index]; + self.ofs += index; + self.x = column; + return result; + } + x += w; + } + let result = &self.line.string[self.ofs..]; + self.x = x; + self.ofs = self.line.string.len(); + result + } +} + +impl<'a, I> Iterator for TextIter<'a, I> +where + I: DoubleEndedIterator, &'a Attribute)>, +{ + type Item = (&'a str, Option); + + fn next(&mut self) -> Option { + if let Some(next) = self.next.take() { + return Some(next); + } + + match self.attr.next() { + Some((interval, attribute)) => { + let start = *interval.start(); + let end = *interval.end() + 1; + if start > self.x { + let this = self.take_up_to(start); + self.next = Some((self.take_up_to(end), Some(*attribute))); + Some((this, None)) + } else { + Some((self.take_up_to(end), Some(*attribute))) + } + } + None => { + let rest = &self.line.string[self.ofs..]; + self.ofs = self.line.string.len(); + if rest.is_empty() { + None + } else { + Some((rest, None)) + } + } + } + } +} + /// Position of one or more characters within a [TextLine]. #[derive(Debug)] struct Position { @@ -223,81 +438,6 @@ impl Iterator for Widths<'_> { } } -#[derive(Copy, Clone, PartialEq, Eq, Sequence)] -pub struct Emphasis { - pub bold: bool, - pub underline: bool, -} - -impl From<&FontStyle> for Emphasis { - fn from(style: &FontStyle) -> Self { - Self { - bold: style.bold, - underline: style.underline, - } - } -} - -impl Emphasis { - const fn plain() -> Self { - Self { - bold: false, - underline: false, - } - } - pub fn is_plain(&self) -> bool { - *self == Self::plain() - } - pub fn apply<'a>(&self, s: &'a str) -> Cow<'a, str> { - if self.is_plain() { - Cow::from(s) - } else { - let mut output = String::with_capacity( - s.len() * (1 + self.bold as usize * 2 + self.underline as usize * 2), - ); - for c in s.chars() { - if self.bold { - output.push(c); - output.push('\x08'); - } - if self.underline { - output.push('_'); - output.push('\x08'); - } - output.push(c); - } - Cow::from(output) - } - } -} - -impl Debug for Emphasis { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - Self { - bold: false, - underline: false, - } => "plain", - Self { - bold: true, - underline: false, - } => "bold", - Self { - bold: false, - underline: true, - } => "underline", - Self { - bold: true, - underline: true, - } => "bold+underline", - } - ) - } -} - pub fn clip_text<'a>( text: &'a str, bb: &Range, @@ -338,21 +478,95 @@ pub fn clip_text<'a>( #[cfg(test)] mod tests { - use super::{Emphasis, TextLine}; - use enum_iterator::all; + use std::fmt::Debug; + + use crate::output::{drivers::text::text_line::Attribute, pivot::look::FontStyle}; + + use super::TextLine; + use enum_iterator::{Sequence, all}; + use itertools::Itertools; + + #[derive(Copy, Clone, Default, PartialEq, Eq, Sequence)] + pub struct Emphasis { + pub bold: bool, + pub italic: bool, + pub underline: bool, + } + + impl From<&FontStyle> for Emphasis { + fn from(style: &FontStyle) -> Self { + Self { + bold: style.bold, + italic: style.italic, + underline: style.underline, + } + } + } + + impl From for Attribute { + fn from(value: Emphasis) -> Self { + Attribute { + bold: value.bold, + italic: value.italic, + underline: value.underline, + ..Attribute::default() + } + } + } + + impl Emphasis { + fn plain() -> Self { + Self::default() + } + pub fn is_plain(&self) -> bool { + *self == Self::plain() + } + pub fn apply<'a>(&self, s: &'a str) -> String { + let (prefix, suffix) = Attribute::from(*self).affixes(); + format!("{prefix}{s}{suffix}") + } + } + + impl Debug for Emphasis { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut attributes = Vec::new(); + if self.bold { + attributes.push("bold"); + } + if self.italic { + attributes.push("italic"); + } + if self.underline { + attributes.push("underline"); + } + if attributes.is_empty() { + write!(f, "plain") + } else { + write!(f, "{}", attributes.into_iter().format("+")) + } + } + } #[test] fn overwrite_rest_of_line() { - for lowercase in all::() { - for uppercase in all::() { - let mut line = TextLine::new(); - line.put(0, &lowercase.apply("abc")); - line.put(1, &uppercase.apply("BCD")); - assert_eq!( - line.str(), - &format!("{}{}", lowercase.apply("a"), uppercase.apply("BCD")), - "uppercase={uppercase:?} lowercase={lowercase:?}" - ); + for (x0, prefix) in [(0, ""), (1, " "), (2, " ")] { + for lowercase in all::() { + for uppercase in all::() { + let mut line = TextLine::new(); + line.put(x0, "abc", Some(lowercase.into())); + line.put(x0 + 1, "BCD", Some(uppercase.into())); + + println!("{}", line.display_sgr()); + assert_eq!( + line.display_wiki().to_string(), + if lowercase == uppercase { + format!("{prefix}{}", lowercase.apply("aBCD")) + } else { + format!("{prefix}{}{}", lowercase.apply("a"), uppercase.apply("BCD")) + }, + "prefix={prefix:?} uppercase={uppercase:?} lowercase={lowercase:?}" + ); + } } } } @@ -363,19 +577,22 @@ mod tests { for uppercase in all::() { let mut line = TextLine::new(); // Produces `AbCDEf`. - line.put(0, &lowercase.apply("abcdef")); - line.put(0, &uppercase.apply("A")); - line.put(2, &uppercase.apply("CDE")); + line.put(0, "abcdef", Some(lowercase.into())); + line.put(0, "A", Some(uppercase.into())); + line.put(2, "CDE", Some(uppercase.into())); assert_eq!( - line.str().replace('\x08', "#"), - format!( - "{}{}{}{}", - uppercase.apply("A"), - lowercase.apply("b"), - uppercase.apply("CDE"), - lowercase.apply("f") - ) - .replace('\x08', "#"), + line.display_wiki().to_string(), + if lowercase == uppercase { + lowercase.apply("AbCDEf") + } else { + format!( + "{}{}{}{}", + uppercase.apply("A"), + lowercase.apply("b"), + uppercase.apply("CDE"), + lowercase.apply("f") + ) + }, "uppercase={uppercase:?} lowercase={lowercase:?}" ); } @@ -387,12 +604,17 @@ mod tests { for lowercase in all::() { for hiragana in all::() { let mut line = TextLine::new(); - // Produces `kaきくけ"`. - line.put(0, &lowercase.apply("kakiku")); - line.put(2, &hiragana.apply("きくけ")); + // Produces `kaきくけ`. + line.put(0, "kakiku", Some(lowercase.into())); + line.put(2, "きくけ", Some(hiragana.into())); + assert_eq!( - line.str(), - &format!("{}{}", lowercase.apply("ka"), hiragana.apply("きくけ")), + line.display_wiki().to_string(), + if lowercase == hiragana { + lowercase.apply("kaきくけ") + } else { + lowercase.apply("ka") + &hiragana.apply("きくけ") + }, "lowercase={lowercase:?} hiragana={hiragana:?}" ); } @@ -404,19 +626,20 @@ mod tests { for lowercase in all::() { for hiragana in all::() { let mut line = TextLine::new(); - // Produces `かkiくけko". - line.put(0, &lowercase.apply("kakikukeko")); - line.put(0, &hiragana.apply("か")); - line.put(4, &hiragana.apply("くけ")); + // Produces `かkiくけko`. + line.put(0, "kakikukeko", Some(lowercase.into())); + line.put(0, "か", Some(hiragana.into())); + line.put(4, "くけ", Some(hiragana.into())); assert_eq!( - line.str(), - &format!( - "{}{}{}{}", - hiragana.apply("か"), - lowercase.apply("ki"), - hiragana.apply("くけ"), - lowercase.apply("ko") - ), + line.display_wiki().to_string(), + if lowercase == hiragana { + lowercase.apply("かkiくけko") + } else { + hiragana.apply("か") + + &lowercase.apply("ki") + + &hiragana.apply("くけ") + + &lowercase.apply("ko") + }, "lowercase={lowercase:?} hiragana={hiragana:?}" ); } @@ -430,11 +653,16 @@ mod tests { for top in all::() { let mut line = TextLine::new(); // Produces `あきくけ`. - line.put(0, &bottom.apply("あいう")); - line.put(2, &top.apply("きくけ")); + line.put(0, "あいう", Some(bottom.into())); + line.put(2, "きくけ", Some(top.into())); + assert_eq!( - line.str(), - &format!("{}{}", bottom.apply("あ"), top.apply("きくけ")), + line.display_wiki().to_string(), + if top == bottom { + top.apply("あきくけ") + } else { + bottom.apply("あ") + &top.apply("きくけ") + }, "bottom={bottom:?} top={top:?}" ); } @@ -448,11 +676,16 @@ mod tests { for top in all::() { let mut line = TextLine::new(); // Produces `あきくけ`. - line.put(0, &bottom.apply("あいう")); - line.put(3, &top.apply("きくけ")); + line.put(0, "あいう", Some(bottom.into())); + line.put(3, "きくけ", Some(top.into())); + assert_eq!( - line.str(), - &format!("{}?{}", bottom.apply("あ"), top.apply("きくけ")), + line.display_wiki().to_string(), + if top == bottom { + top.apply("あ?きくけ") + } else { + bottom.apply("あ?") + &top.apply("きくけ") + }, "bottom={bottom:?} top={top:?}" ); } @@ -466,18 +699,19 @@ mod tests { for top in all::() { let mut line = TextLine::new(); // Produces `かいくけお`. - line.put(0, &bottom.apply("あいうえお")); - line.put(0, &top.apply("か")); - line.put(4, &top.apply("くけ")); + line.put(0, "あいうえお", Some(bottom.into())); + line.put(0, "か", Some(top.into())); + line.put(4, "くけ", Some(top.into())); assert_eq!( - line.str(), - &format!( - "{}{}{}{}", - top.apply("か"), - bottom.apply("い"), - top.apply("くけ"), - bottom.apply("お") - ), + line.display_wiki().to_string(), + if top == bottom { + top.apply("かいくけお") + } else { + top.apply("か") + + &bottom.apply("い") + + &top.apply("くけ") + + &bottom.apply("お") + }, "bottom={bottom:?} top={top:?}" ); } @@ -491,24 +725,32 @@ mod tests { for top in all::() { let mut line = TextLine::new(); // Produces `?か?うえおさ`. - line.put(0, &bottom.apply("あいうえおさ")); - line.put(1, &top.apply("か")); + line.put(0, "あいうえおさ", Some(bottom.into())); + line.put(1, "か", Some(top.into())); assert_eq!( - line.str(), - &format!("?{}?{}", top.apply("か"), bottom.apply("うえおさ"),), + line.display_wiki().to_string(), + if top == bottom { + top.apply("?か?うえおさ") + } else { + bottom.apply("?") + &top.apply("か") + &bottom.apply("?うえおさ") + }, "bottom={bottom:?} top={top:?}" ); // Produces `?か??くけ?さ`. - line.put(5, &top.apply("くけ")); + line.put(5, "くけ", Some(top.into())); + assert_eq!( - line.str(), - &format!( - "?{}??{}?{}", - top.apply("か"), - top.apply("くけ"), - bottom.apply("さ") - ), + line.display_wiki().to_string(), + if top == bottom { + top.apply("?か??くけ?さ") + } else { + bottom.apply("?") + + &top.apply("か") + + &bottom.apply("??") + + &top.apply("くけ") + + &bottom.apply("?さ") + }, "bottom={bottom:?} top={top:?}" ); } @@ -522,11 +764,15 @@ mod tests { for top in all::() { let mut line = TextLine::new(); // Produces `あkikuko`. - line.put(0, &bottom.apply("あいう")); - line.put(2, &top.apply("kikuko")); + line.put(0, "あいう", Some(bottom.into())); + line.put(2, "kikuko", Some(top.into())); assert_eq!( - line.str(), - &format!("{}{}", bottom.apply("あ"), top.apply("kikuko"),), + line.display_wiki().to_string(), + if top == bottom { + top.apply("あkikuko") + } else { + bottom.apply("あ") + &top.apply("kikuko") + }, "bottom={bottom:?} top={top:?}" ); } @@ -540,11 +786,15 @@ mod tests { for top in all::() { let mut line = TextLine::new(); // Produces `あ?kikuko`. - line.put(0, &bottom.apply("あいう")); - line.put(3, &top.apply("kikuko")); + line.put(0, "あいう", Some(bottom.into())); + line.put(3, "kikuko", Some(top.into())); assert_eq!( - line.str(), - &format!("{}?{}", bottom.apply("あ"), top.apply("kikuko"),), + line.display_wiki().to_string(), + if top == bottom { + top.apply("あ?kikuko") + } else { + bottom.apply("あ?") + &top.apply("kikuko") + }, "bottom={bottom:?} top={top:?}" ); } @@ -558,25 +808,30 @@ mod tests { for top in all::() { let mut line = TextLine::new(); // Produces `kaいうえお`. - line.put(0, &bottom.apply("あいうえお")); - line.put(0, &top.apply("ka")); + line.put(0, "あいうえお", Some(bottom.into())); + line.put(0, "ka", Some(top.into())); + assert_eq!( - line.str(), - &format!("{}{}", top.apply("ka"), bottom.apply("いうえお"),), + line.display_wiki().to_string(), + if top == bottom { + top.apply("kaいうえお") + } else { + top.apply("ka") + &bottom.apply("いうえお") + }, "bottom={bottom:?} top={top:?}" ); - // Produces `kaいkukeお`. - line.put(4, &top.apply("kuke")); + line.put(4, "kuke", Some(top.into())); assert_eq!( - line.str(), - &format!( - "{}{}{}{}", - top.apply("ka"), - bottom.apply("い"), - top.apply("kuke"), - bottom.apply("お") - ), + line.display_wiki().to_string(), + if top == bottom { + top.apply("kaいkukeお") + } else { + top.apply("ka") + + &bottom.apply("い") + + &top.apply("kuke") + + &bottom.apply("お") + }, "bottom={bottom:?} top={top:?}" ); } @@ -590,25 +845,31 @@ mod tests { for top in all::() { let mut line = TextLine::new(); // Produces `?aいうえおさ`. - line.put(0, &bottom.apply("あいうえおさ")); - line.put(1, &top.apply("a")); + line.put(0, "あいうえおさ", Some(bottom.into())); + line.put(1, "a", Some(top.into())); assert_eq!( - line.str(), - &format!("?{}{}", top.apply("a"), bottom.apply("いうえおさ"),), + line.display_wiki().to_string(), + if top == bottom { + top.apply("?aいうえおさ") + } else { + bottom.apply("?") + &top.apply("a") + &bottom.apply("いうえおさ") + }, "bottom={bottom:?} top={top:?}" ); // Produces `?aい?kuke?さ`. - line.put(5, &top.apply("kuke")); + line.put(5, "kuke", Some(top.into())); assert_eq!( - line.str(), - &format!( - "?{}{}?{}?{}", - top.apply("a"), - bottom.apply("い"), - top.apply("kuke"), - bottom.apply("さ") - ), + line.display_wiki().to_string(), + if top == bottom { + top.apply("?aい?kuke?さ") + } else { + bottom.apply("?") + + &top.apply("a") + + &bottom.apply("い?") + + &top.apply("kuke") + + &bottom.apply("?さ") + }, "bottom={bottom:?} top={top:?}" ); } diff --git a/rust/pspp/src/output/pivot.rs b/rust/pspp/src/output/pivot.rs index e120066de7..f6431c7748 100644 --- a/rust/pspp/src/output/pivot.rs +++ b/rust/pspp/src/output/pivot.rs @@ -194,6 +194,7 @@ impl PivotTable { self } + /// Returns this pivot table with the given `title`, if it is non-`None`. pub fn with_optional_title(self, title: Option) -> Self { if let Some(title) = title { self.with_title(title) @@ -211,6 +212,7 @@ impl PivotTable { self } + /// Returns this pivot table with the given `caption`, if it is non-`None`. pub fn with_optional_caption(self, caption: Option) -> Self { if let Some(caption) = caption { self.with_caption(caption) diff --git a/rust/pspp/src/spv/read.rs b/rust/pspp/src/spv/read.rs index 78902bf9e5..6a8c2c58dd 100644 --- a/rust/pspp/src/spv/read.rs +++ b/rust/pspp/src/spv/read.rs @@ -41,6 +41,7 @@ use crate::{ mod css; pub mod html; pub mod legacy_bin; +#[allow(missing_docs)] pub mod legacy_xml; mod light; pub mod structure; diff --git a/rust/pspp/src/spv/read/legacy_xml.rs b/rust/pspp/src/spv/read/legacy_xml.rs index fe5bfd8e9d..0b79aeee6b 100644 --- a/rust/pspp/src/spv/read/legacy_xml.rs +++ b/rust/pspp/src/spv/read/legacy_xml.rs @@ -284,6 +284,7 @@ impl Visualization { graph: &Graph, series: &'a BTreeMap<&str, Series>, footnotes: &pivot::Footnotes, + styles: &HashMap<&str, &Style>, ) -> (Vec>, Vec) { let axes = graph .facet_layout @@ -292,12 +293,6 @@ impl Visualization { .filter_map(|child| child.facet_level()) .map(|facet_level| (facet_level.level, &facet_level.axis)) .collect::>(); - let styles = self - .children - .iter() - .filter_map(|child| child.style()) - .filter_map(|style| style.id.as_ref().map(|id| (id.as_str(), style))) - .collect::>(); let mut dims = Vec::new(); let mut level_ofs = 1; let mut current_layer = Vec::new(); @@ -355,10 +350,18 @@ impl Visualization { let (title, caption, footnotes) = self.decode_labels(graph); let series = self.decode_series(binary_data, warn); - let (mut dims, current_layer) = self.decode_dimensions(graph, &series, &footnotes); + let styles = self + .children + .iter() + .filter_map(|child| child.style()) + .filter_map(|style| style.id.as_ref().map(|id| (id.as_str(), style))) + .collect::>(); + let (mut dims, current_layer) = self.decode_dimensions(graph, &series, &footnotes, &styles); let mut data = graph.decode_data(&footnotes, &dims, &series, warn); - graph.decode_styles(&look, &series, &mut dims, &mut data, &footnotes, warn); + graph.decode_styles( + &look, &series, &mut dims, &mut data, &footnotes, &styles, warn, + ); Ok(PivotTable::new( dims.into_iter() @@ -1375,9 +1378,8 @@ impl Graph { 'outer: for (i, cell) in cell.values.iter().enumerate() { coords.clear(); for dim in dims { - if let Some(Some(coordinate)) = dim.coordinate.categories.get(i) - && let Some(locator) = - dim.coordinate.coordinate_to_index.borrow().get(&coordinate) + if let Some(Some(coordinate)) = dim.series.categories.get(i) + && let Some(locator) = dim.series.coordinate_to_index.borrow().get(&coordinate) && let Some(index) = locator.as_leaf() { coords.push(index); @@ -1431,6 +1433,7 @@ impl Graph { dims: &mut [Dim], data: &mut HashMap, Value>, footnotes: &pivot::Footnotes, + styles: &HashMap<&str, &Style>, warn: &mut dyn FnMut(LegacyXmlWarning), ) { let has_cell_footnotes = series.contains_key("footnotes"); @@ -1440,140 +1443,10 @@ impl Graph { .iter() .filter_map(|child| child.set_cell_properties()) { - #[derive(Copy, Clone, Debug, PartialEq)] - enum TargetType { - Labeling, - MajorTicks, - } - - impl TargetType { - fn from_id(target: &str) -> Option { - if target == "labeling" { - Some(Self::Labeling) - } else if target.ends_with("majorTicks") { - Some(Self::MajorTicks) - } else { - None - } - } - } - - #[derive(Debug)] - struct Target<'a> { - sf: &'a SetFormat, - target_type: TargetType, - } - impl<'a> Target<'a> { - fn decode( - &self, - intersect: &Intersect, - look: &Look, - series: &BTreeMap<&str, Series>, - dims: &mut [Dim], - data: &mut HashMap, Value>, - footnotes: &pivot::Footnotes, - has_cell_footnotes: bool, - ) { - match self.target_type { - TargetType::MajorTicks => { - // Formatting for individual row or column labels. - for w in intersect.wheres() { - let Some(s) = series.get(w.variable.as_str()) else { - continue; - }; - let Some(dim_index) = s.dimension_index.get() else { - continue; - }; - let dimension = &mut dims[dim_index].dimension; - let Ok(axis) = Axis2::try_from(dims[dim_index].axis) else { - continue; - }; - for index in - w.include.split(';').filter_map(|s| s.parse::().ok()) - { - if let Some(locator) = - s.coordinate_to_index.borrow().get(&index).copied() - && let Some(category) = dimension.root.category_mut(locator) - { - Style::apply_to_value( - category.name_mut(), - Some(&self.sf), - None, - None, - &look.areas[Area::Labels(axis)], - footnotes, - has_cell_footnotes, - ); - } - } - } - } - TargetType::Labeling => { - // Formatting for individual cells or groups of them - // with some dimensions in common. - let mut include = vec![HashSet::new(); dims.len()]; - for w in intersect.wheres() { - let Some(s) = series.get(w.variable.as_str()) else { - continue; - }; - let Some(dim_index) = s.dimension_index.get() else { - // Group indexes may be included even though - // they are redundant. Ignore them. - continue; - }; - for index in - w.include.split(';').filter_map(|s| s.parse::().ok()) - { - if let Some(locator) = - s.coordinate_to_index.borrow().get(&index).copied() - && let Some(leaf_index) = locator.as_leaf() - { - include[dim_index].insert(leaf_index); - } - } - } - - // XXX This is inefficient in the common case where - // all of the dimensions are matched. We should use - // a heuristic where if all of the dimensions are - // matched and the product of n[*] is less than the - // number of cells then iterate through all the - // possibilities rather than all the cells. Or even - // only do it if there is just one possibility. - for (indexes, value) in data { - let mut skip = false; - for (dimension, index) in indexes.iter().enumerate() { - if !include[dimension].is_empty() - && !include[dimension].contains(index) - { - skip = true; - break; - } - } - if !skip { - Style::apply_to_value( - value, - Some(&self.sf), - None, - None, - &look.areas[Area::Data(RowParity::Even)], - footnotes, - has_cell_footnotes, - ); - } - } - } - } - } - } - let targets = scp .sets .iter() - .filter_map(|set| set.as_set_format()) - .filter_map(|sf| { - TargetType::from_id(&sf.target).map(|target_type| Target { sf, target_type }) - }) + .filter_map(|set| set.decode(styles)) .collect::>(); if let Some(union_) = &scp.union_ { @@ -1599,13 +1472,10 @@ impl Graph { for target in &targets { if target.target_type == TargetType::Labeling { for value in data.values_mut() { - Style::apply_to_value( + target.apply( value, - Some(&target.sf), - None, - None, &look.areas[Area::Data(RowParity::Even)], - &footnotes, + footnotes, has_cell_footnotes, ); } @@ -1800,21 +1670,206 @@ struct Where { include: String, } +#[derive(Copy, Clone, Debug, PartialEq)] +enum TargetType { + Labeling, + MajorTicks, + Interval, +} + +impl TargetType { + fn from_id(target: &str) -> Option { + if target == "interval" { + Some(Self::Interval) + } else if target == "labeling" { + Some(Self::Labeling) + } else if target.ends_with("majorTicks") { + Some(Self::MajorTicks) + } else { + None + } + } +} + #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] enum Set { SetFormat(SetFormat), + SetStyle(SetStyle), #[serde(other)] Other, } impl Set { - fn as_set_format(&self) -> Option<&SetFormat> { + fn decode<'a>(&'a self, styles: &HashMap<&str, &'a Style>) -> Option> { + Some(DecodedSet { + target_type: self.target_type()?, + change: match self { + Set::SetFormat(set_format) => Change::Format(set_format), + Set::SetStyle(set_style) => Change::Style(set_style.style.get(styles)?), + Set::Other => return None, + }, + }) + } + + fn target(&self) -> Option<&str> { match self { - Set::SetFormat(set_format) => Some(set_format), + Set::SetFormat(set_format) => Some(&set_format.target), + Set::SetStyle(set_style) => Some(&set_style.target), Set::Other => None, } } + + fn target_type(&self) -> Option { + self.target().and_then(TargetType::from_id) + } +} + +struct DecodedSet<'a> { + target_type: TargetType, + change: Change<'a>, +} + +impl<'a> DecodedSet<'a> { + fn decode( + &self, + intersect: &Intersect, + look: &Look, + series: &BTreeMap<&str, Series>, + dims: &mut [Dim], + data: &mut HashMap, Value>, + footnotes: &pivot::Footnotes, + has_cell_footnotes: bool, + ) { + match self.target_type { + TargetType::MajorTicks => { + // Formatting for individual row or column labels. + for w in intersect.wheres() { + let Some(s) = series.get(w.variable.as_str()) else { + continue; + }; + let Some(dim_index) = s.dimension_index.get() else { + continue; + }; + let dimension = &mut dims[dim_index].dimension; + let Ok(axis) = Axis2::try_from(dims[dim_index].axis) else { + continue; + }; + for index in w.include.split(';').filter_map(|s| s.parse::().ok()) { + if let Some(locator) = s.coordinate_to_index.borrow().get(&index).copied() + && let Some(category) = dimension.root.category_mut(locator) + { + self.apply( + category.name_mut(), + &look.areas[Area::Labels(axis)], + footnotes, + has_cell_footnotes, + ); + } + } + } + } + TargetType::Interval | TargetType::Labeling => { + // Formatting for individual cells or groups of them + // with some dimensions in common. + let mut include = vec![HashSet::new(); dims.len()]; + for w in intersect.wheres() { + let Some(s) = series.get(w.variable.as_str()) else { + continue; + }; + let Some(dim_index) = s.dimension_index.get() else { + // Group indexes may be included even though + // they are redundant. Ignore them. + continue; + }; + for index in w.include.split(';').filter_map(|s| s.parse::().ok()) { + if let Some(locator) = s.coordinate_to_index.borrow().get(&index).copied() + && let Some(leaf_index) = locator.as_leaf() + { + include[dim_index].insert(leaf_index); + } + } + } + + // XXX This is inefficient in the common case where + // all of the dimensions are matched. We should use + // a heuristic where if all of the dimensions are + // matched and the product of n[*] is less than the + // number of cells then iterate through all the + // possibilities rather than all the cells. Or even + // only do it if there is just one possibility. + for (indexes, value) in data { + let mut skip = false; + for (dimension, index) in indexes.iter().enumerate() { + if !include[dimension].is_empty() && !include[dimension].contains(index) { + skip = true; + break; + } + } + if !skip { + self.apply( + value, + &look.areas[Area::Data(RowParity::Even)], + footnotes, + has_cell_footnotes, + ); + } + } + } + } + } + + fn apply( + &self, + value: &mut Value, + base_style: &AreaStyle, + footnotes: &pivot::Footnotes, + has_cell_footnotes: bool, + ) { + match self.change { + Change::Format(set_format) => Style::apply_to_value( + value, + Some(set_format), + None, + None, + base_style, + footnotes, + has_cell_footnotes, + ), + Change::Style(style) => { + let (fg, bg) = if self.target_type == TargetType::Interval { + (None, Some(style)) + } else { + (Some(style), None) + }; + Style::apply_to_value( + value, + None, + fg, + bg, + base_style, + footnotes, + has_cell_footnotes, + ) + } + } + } +} + +#[derive(Copy, Clone, Debug)] +enum Change<'a> { + Format(&'a SetFormat), + Style(&'a Style), +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct SetStyle { + #[serde(rename = "@target")] + target: String, + + #[serde(rename = "@style")] + style: Ref