Skip to main content

ggplot_rs/render/
rich.rs

1//! Rich text labels (#413, ggtext's `element_markdown()`): a small
2//! Markdown / HTML subset in titles, axis titles, strip and legend text when
3//! [`Theme::rich_text`](crate::theme::Theme::rich_text) is on.
4//!
5//! Supported: `**bold**`, `*italic*` / `_italic_`, `^super^` / `~sub~`,
6//! `<b>`, `<i>`, `<sup>`, `<sub>`, `<span style='color:#rrggbb'>` (or a basic CSS
7//! colour name: `red`, `steelblue`, …) and `\*` escapes.
8//! Anything else is literal text.
9
10use super::backend::{DrawBackend, FontFace, TextStyle};
11use super::RenderError;
12
13/// One run of uniformly styled text.
14#[derive(Clone, Debug, PartialEq)]
15pub struct RichRun {
16    pub text: String,
17    pub bold: bool,
18    pub italic: bool,
19    /// `1` superscript, `-1` subscript, `0` normal.
20    pub shift: i8,
21    pub color: Option<(u8, u8, u8)>,
22}
23
24impl RichRun {
25    /// The face this run draws in, given the element's own.
26    pub fn face(&self, base: FontFace) -> FontFace {
27        if self.bold || base == FontFace::Bold {
28            FontFace::Bold
29        } else if self.italic || base == FontFace::Italic {
30            FontFace::Italic
31        } else {
32            FontFace::Plain
33        }
34    }
35}
36
37#[derive(Clone, Copy, Default)]
38struct State {
39    bold: bool,
40    italic: bool,
41    shift: i8,
42    color: Option<(u8, u8, u8)>,
43}
44
45fn parse_color(css: &str) -> Option<(u8, u8, u8)> {
46    let v = css.split(';').find_map(|d| {
47        let (k, v) = d.split_once(':')?;
48        (k.trim().eq_ignore_ascii_case("color")).then(|| v.trim().to_string())
49    })?;
50    if let Some(hex) = v.strip_prefix('#') {
51        let n = |i: usize| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok();
52        return match hex.len() {
53            6 => Some((n(0)?, n(2)?, n(4)?)),
54            3 => {
55                let d = |i: usize| {
56                    u8::from_str_radix(hex.get(i..i + 1)?, 16)
57                        .ok()
58                        .map(|x| x * 17)
59                };
60                Some((d(0)?, d(1)?, d(2)?))
61            }
62            _ => None,
63        };
64    }
65    let named = [
66        ("black", (0, 0, 0)),
67        ("white", (255, 255, 255)),
68        ("red", (255, 0, 0)),
69        ("green", (0, 128, 0)),
70        ("blue", (0, 0, 255)),
71        ("grey", (128, 128, 128)),
72        ("gray", (128, 128, 128)),
73        ("orange", (255, 165, 0)),
74        ("purple", (128, 0, 128)),
75        ("darkred", (139, 0, 0)),
76        ("darkgreen", (0, 100, 0)),
77        ("darkblue", (0, 0, 139)),
78        ("navy", (0, 0, 128)),
79        ("steelblue", (70, 130, 180)),
80        ("firebrick", (178, 34, 34)),
81        ("forestgreen", (34, 139, 34)),
82        ("gold", (255, 215, 0)),
83        ("brown", (165, 42, 42)),
84    ];
85    let v = v.to_ascii_lowercase();
86    named.iter().find(|(n, _)| *n == v).map(|(_, c)| *c)
87}
88
89/// Does `text` contain any markup [`parse`] would interpret?
90pub fn has_markup(text: &str) -> bool {
91    text.contains("**")
92        || text.contains('*')
93        || text.contains('_')
94        || text.contains('^')
95        || text.contains('~')
96        || text.contains('<')
97        || text.contains('\\')
98}
99
100/// Split `text` into styled runs.
101pub fn parse(text: &str) -> Vec<RichRun> {
102    let mut runs: Vec<RichRun> = Vec::new();
103    let mut stack: Vec<State> = vec![State::default()];
104    let mut buf = String::new();
105    let flush = |buf: &mut String, runs: &mut Vec<RichRun>, st: State| {
106        if buf.is_empty() {
107            return;
108        }
109        let run = RichRun {
110            text: std::mem::take(buf),
111            bold: st.bold,
112            italic: st.italic,
113            shift: st.shift,
114            color: st.color,
115        };
116        match runs.last_mut() {
117            Some(last)
118                if last.bold == run.bold
119                    && last.italic == run.italic
120                    && last.shift == run.shift
121                    && last.color == run.color =>
122            {
123                last.text.push_str(&run.text)
124            }
125            _ => runs.push(run),
126        }
127    };
128    // Open markdown delimiters, so a second `**` closes the first.
129    let mut open_md: Vec<&'static str> = Vec::new();
130    let mut rest = text;
131    while let Some(ch) = rest.chars().next() {
132        let cur = *stack.last().unwrap();
133        // Escapes.
134        if ch == '\\' {
135            if let Some(next) = rest[1..].chars().next() {
136                buf.push(next);
137                rest = &rest[1 + next.len_utf8()..];
138            } else {
139                buf.push('\\');
140                rest = &rest[1..];
141            }
142            continue;
143        }
144        // Markdown delimiters.
145        let md = ["**", "*", "_", "^", "~"]
146            .into_iter()
147            .find(|d| rest.starts_with(d));
148        if let Some(d) = md {
149            let closing = open_md.last() == Some(&d);
150            // An opener needs a matching closer later on.
151            if closing || rest[d.len()..].contains(d) {
152                flush(&mut buf, &mut runs, cur);
153                if closing {
154                    open_md.pop();
155                    stack.pop();
156                } else {
157                    open_md.push(d);
158                    let mut s = cur;
159                    match d {
160                        "**" => s.bold = true,
161                        "*" | "_" => s.italic = true,
162                        "^" => s.shift = 1,
163                        _ => s.shift = -1,
164                    }
165                    stack.push(s);
166                }
167                rest = &rest[d.len()..];
168                continue;
169            }
170        }
171        // HTML tags.
172        if ch == '<' {
173            if let Some(end) = rest.find('>') {
174                let tag = &rest[1..end];
175                let lower = tag.trim().to_ascii_lowercase();
176                let (closing, name) = match lower.strip_prefix('/') {
177                    Some(n) => (true, n.trim().to_string()),
178                    None => (
179                        false,
180                        lower.split_whitespace().next().unwrap_or("").to_string(),
181                    ),
182                };
183                if matches!(
184                    name.as_str(),
185                    "b" | "strong" | "i" | "em" | "sup" | "sub" | "span"
186                ) {
187                    flush(&mut buf, &mut runs, cur);
188                    if closing {
189                        if stack.len() > 1 {
190                            stack.pop();
191                        }
192                    } else {
193                        let mut s = cur;
194                        match name.as_str() {
195                            "b" | "strong" => s.bold = true,
196                            "i" | "em" => s.italic = true,
197                            "sup" => s.shift = 1,
198                            "sub" => s.shift = -1,
199                            _ => {
200                                // style='…' or style="…"
201                                if let Some(i) = tag.find("style=") {
202                                    let v = &tag[i + 6..];
203                                    let q = v.chars().next().unwrap_or('"');
204                                    let body = v[q.len_utf8()..].split(q).next().unwrap_or("");
205                                    if let Some(c) = parse_color(body) {
206                                        s.color = Some(c);
207                                    }
208                                }
209                            }
210                        }
211                        stack.push(s);
212                    }
213                    rest = &rest[end + 1..];
214                    continue;
215                }
216            }
217        }
218        buf.push(ch);
219        rest = &rest[ch.len_utf8()..];
220    }
221    let cur = *stack.last().unwrap();
222    flush(&mut buf, &mut runs, cur);
223    runs
224}
225
226/// The font size of a shifted (super / subscript) run.
227pub fn run_size(size: f64, shift: i8) -> f64 {
228    if shift == 0 {
229        size
230    } else {
231        size * 0.7
232    }
233}
234
235/// Draw `text` — as rich runs when `rich` is on and it carries markup,
236/// else exactly as [`DrawBackend::draw_text`] (byte-identical).
237pub fn draw(
238    backend: &mut dyn DrawBackend,
239    rich: bool,
240    text: &str,
241    pos: (f64, f64),
242    style: &TextStyle,
243) -> Result<(), RenderError> {
244    if rich && has_markup(text) {
245        let runs = parse(text);
246        backend.draw_rich_text(&runs, pos, style)
247    } else {
248        backend.draw_text(text, pos, style)
249    }
250}
251
252/// The text with all markup removed (for width estimates and fallbacks).
253pub fn plain(text: &str) -> String {
254    parse(text).into_iter().map(|r| r.text).collect()
255}
256
257/// Measures a run: `(text, size, face) -> width` in px.
258pub type RunWidth<'a> = &'a dyn Fn(&str, f64, FontFace) -> f64;
259
260/// Lay `runs` out left to right (along the text's angle): each run's text,
261/// start point and style, measuring a run with `width(text, size, face)` —
262/// what a backend without native rich text draws with
263/// [`DrawBackend::draw_text`].
264pub fn layout_runs(
265    runs: &[RichRun],
266    (x, y): (f64, f64),
267    style: &TextStyle,
268    width: RunWidth<'_>,
269) -> Vec<(String, (f64, f64), TextStyle)> {
270    use super::backend::TextAnchor;
271    let widths: Vec<f64> = runs
272        .iter()
273        .map(|r| width(&r.text, run_size(style.size, r.shift), r.face(style.face)))
274        .collect();
275    let total: f64 = widths.iter().sum();
276    let lead = match style.anchor {
277        TextAnchor::Start => 0.0,
278        TextAnchor::Middle => total / 2.0,
279        TextAnchor::End => total,
280    };
281    let angle = if style.angle.is_finite() {
282        style.angle
283    } else {
284        0.0
285    };
286    let (sin, cos) = angle.to_radians().sin_cos();
287    let mut along = -lead;
288    let mut out = Vec::with_capacity(runs.len());
289    for (run, w) in runs.iter().zip(&widths) {
290        // A superscript sits a third of an em up, a subscript a fifth down.
291        let up = match run.shift {
292            1 => style.size * 0.33,
293            -1 => -style.size * 0.2,
294            _ => 0.0,
295        };
296        let px = x + along * cos + up * sin;
297        let py = y + along * sin - up * cos;
298        out.push((
299            run.text.clone(),
300            (px, py),
301            TextStyle {
302                size: run_size(style.size, run.shift),
303                face: run.face(style.face),
304                color: run.color.unwrap_or(style.color),
305                anchor: TextAnchor::Start,
306                ..style.clone()
307            },
308        ));
309        along += w;
310    }
311    out
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    type Summary = (String, bool, bool, i8, Option<(u8, u8, u8)>);
319
320    fn summary(t: &str) -> Vec<Summary> {
321        parse(t)
322            .into_iter()
323            .map(|r| (r.text, r.bold, r.italic, r.shift, r.color))
324            .collect()
325    }
326
327    #[test]
328    fn markdown_and_html() {
329        assert_eq!(
330            summary("a **b** *c* x^2^ H~2~O"),
331            vec![
332                ("a ".into(), false, false, 0, None),
333                ("b".into(), true, false, 0, None),
334                (" ".into(), false, false, 0, None),
335                ("c".into(), false, true, 0, None),
336                (" x".into(), false, false, 0, None),
337                ("2".into(), false, false, 1, None),
338                (" H".into(), false, false, 0, None),
339                ("2".into(), false, false, -1, None),
340                ("O".into(), false, false, 0, None),
341            ]
342        );
343        assert_eq!(
344            summary("<span style='color:#ff0000'>red <b>bold</b></span>!"),
345            vec![
346                ("red ".into(), false, false, 0, Some((255, 0, 0))),
347                ("bold".into(), true, false, 0, Some((255, 0, 0))),
348                ("!".into(), false, false, 0, None),
349            ]
350        );
351    }
352
353    #[test]
354    fn unmatched_delimiters_and_escapes_stay_literal() {
355        assert_eq!(plain("3 * 4 = 12"), "3 * 4 = 12");
356        assert_eq!(plain("snake_case"), "snake_case");
357        assert_eq!(plain(r"\*not italic\*"), "*not italic*");
358        assert_eq!(plain("a < b"), "a < b");
359        assert_eq!(plain("<u>x</u>"), "<u>x</u>");
360    }
361}