work on colored text output (for testing spv output) rust
authorBen Pfaff <blp@cs.stanford.edu>
Mon, 5 Jan 2026 04:00:23 +0000 (20:00 -0800)
committerBen Pfaff <blp@cs.stanford.edu>
Mon, 5 Jan 2026 04:00:23 +0000 (20:00 -0800)
rust/Cargo.lock
rust/pspp/Cargo.toml
rust/pspp/src/output/drivers/text.rs
rust/pspp/src/output/drivers/text/text_line.rs
rust/pspp/src/output/pivot.rs
rust/pspp/src/spv/read.rs
rust/pspp/src/spv/read/legacy_xml.rs

index 48a5a75043b17a57391b341605046c6d91e6e524..5257a627bb64d48e95fa2d367bd77266460a0c9d 100644 (file)
@@ -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",
index 41900972829d69dfa22fb7071c6cf920376f0742..6bda045b35ee58e2f7c05a2eab136dac9cfb249f 100644 (file)
@@ -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"] }
index a9fc41917dcdc563ef3dc2ed3347fa85643d6afc..6f5fa1601ea05529937c5ab81a4f649613d99aa9 100644 (file)
@@ -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);
         }
     }
 
index bd31c5f932201a19d180727b87ab32c013106f1c..e4f992c92576b1c10e17f6adfb4729f917d6083c 100644 (file)
 // You should have received a copy of the GNU General Public License along with
 // this program.  If not, see <http://www.gnu.org/licenses/>.
 
-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<Color>,
+    pub bg: Option<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
+    }
+
+    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<T>(&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<usize, Interval<usize>, Attribute>,
+
     /// Display width, in character positions.
     width: usize,
 }
@@ -68,7 +152,7 @@ impl TextLine {
         self.width = x;
     }
 
-    fn put_closure<F>(&mut self, x0: usize, w: usize, push_str: F)
+    fn put_closure<F>(&mut self, x0: usize, w: usize, push_str: F, attribute: Option<Attribute>)
     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<Attribute>) {
         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<Attribute>) {
         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<Item = (&'_ str, Option<Attribute>)> {
+        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<Item = (&'a Interval<usize>, &'a Attribute)>,
+{
+    line: &'a TextLine,
+    next: Option<(&'a str, Option<Attribute>)>,
+    attr: I,
+    ofs: usize,
+    x: usize,
+}
+
+impl<'a, I> TextIter<'a, I>
+where
+    I: DoubleEndedIterator<Item = (&'a Interval<usize>, &'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<Item = (&'a Interval<usize>, &'a Attribute)>,
+{
+    type Item = (&'a str, Option<Attribute>);
+
+    fn next(&mut self) -> Option<Self::Item> {
+        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<isize>,
@@ -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<Emphasis> 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::<Emphasis>() {
-            for uppercase in all::<Emphasis>() {
-                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::<Emphasis>() {
+                for uppercase in all::<Emphasis>() {
+                    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::<Emphasis>() {
                 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::<Emphasis>() {
             for hiragana in all::<Emphasis>() {
                 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::<Emphasis>() {
             for hiragana in all::<Emphasis>() {
                 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::<Emphasis>() {
                 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::<Emphasis>() {
                 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::<Emphasis>() {
                 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::<Emphasis>() {
                 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::<Emphasis>() {
                 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::<Emphasis>() {
                 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::<Emphasis>() {
                 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::<Emphasis>() {
                 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:?}"
                 );
             }
index e120066de7a3f415979e16a5986fa03fc2db8ee9..f6431c77486fde851756adc206244e9b52fb046d 100644 (file)
@@ -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<Value>) -> 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<Value>) -> Self {
         if let Some(caption) = caption {
             self.with_caption(caption)
index 78902bf9e5e606d0d79376a93733c30bfb34af9f..6a8c2c58dde50a76ca527bdc3fc40af71b1711af 100644 (file)
@@ -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;
index fe5bfd8e9dd90b930f437ff155241f5758a778a8..0b79aeee6ba246875832e0ef7d7cada3bc8751de 100644 (file)
@@ -284,6 +284,7 @@ impl Visualization {
         graph: &Graph,
         series: &'a BTreeMap<&str, Series>,
         footnotes: &pivot::Footnotes,
+        styles: &HashMap<&str, &Style>,
     ) -> (Vec<Dim<'a>>, Vec<usize>) {
         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::<HashMap<_, _>>();
-        let styles = self
-            .children
-            .iter()
-            .filter_map(|child| child.style())
-            .filter_map(|style| style.id.as_ref().map(|id| (id.as_str(), style)))
-            .collect::<HashMap<_, _>>();
         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::<HashMap<_, _>>();
+        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<Vec<usize>, 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<Self> {
-                    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<Vec<usize>, 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::<usize>().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::<usize>().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::<Vec<_>>();
 
             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<Self> {
+        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<DecodedSet<'a>> {
+        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<TargetType> {
+        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<Vec<usize>, 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::<usize>().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::<usize>().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<Style>,
 }
 
 #[derive(Deserialize, Debug)]
@@ -2261,7 +2316,7 @@ fn decode_dimension<'a>(
     dims.push(Dim {
         axis: a,
         dimension,
-        coordinate: variables[0],
+        series: variables[0],
     });
 }
 
@@ -2306,5 +2361,5 @@ fn decode_axis_dimensions<'a, 'b>(
 struct Dim<'a> {
     axis: Axis3,
     dimension: pivot::Dimension,
-    coordinate: &'a Series,
+    series: &'a Series,
 }