bbml/
lib.rs

1//! Renders [BbML](https://blackboard.github.io/rest-apis/learn/advanced/bbml) (a subset of HTML) to styled text for [`ratatui`]
2use log::debug;
3use ratatui::{
4    style::{Color, Modifier, Style},
5    text::{Line, Span, Text},
6    widgets::{Paragraph, Wrap},
7};
8use tl::{HTMLTag, Node, NodeHandle, VDom};
9
10const SCREEN_WIDTH: usize = 70;
11const TABLE_VERTICAL_BORDER: char = '─';
12const TABLE_MID_LEFT_BORDER: char = '├';
13const TABLE_MID_INTERSECT: char = '┼';
14const TABLE_MID_RIGHT_BORDER: char = '┤';
15const TABLE_TOP_LEFT_BORDER: char = '┌';
16const TABLE_TOP_INTERSECT: char = '┬';
17const TABLE_BOT_INTERSECT: char = '┴';
18const TABLE_TOP_RIGHT_BORDER: char = '┐';
19const TABLE_BOT_LEFT_BORDER: char = '└';
20const TABLE_BOT_RIGHT_BORDER: char = '┘';
21const TABLE_HORIZ_BORDER: char = '│';
22
23/// Render the given bbml as best as possible.
24/// Returns the rendered text as a paragraph, and a list of links inside that text
25pub fn render(html: &str) -> (Paragraph<'static>, Vec<String>) {
26    let mut state = RenderState::new(html);
27    let (mut text, links) = state.render();
28
29    cleanup(&mut text);
30
31    (Paragraph::new(text).wrap(Wrap { trim: false }), links)
32}
33
34/// State needed throughout the rendering process
35struct RenderState<'a> {
36    /// Handle into our DOM, since [`tl`] is 0-copy
37    dom: VDom<'a>,
38}
39
40impl<'a> RenderState<'a> {
41    /// Initialise render state with the given HTML
42    fn new(html: &'a str) -> RenderState<'a> {
43        let dom = tl::parse(html, tl::ParserOptions::default()).unwrap();
44        Self { dom }
45    }
46
47    /// Render everything into a text object
48    fn render(&mut self) -> (Text<'static>, Vec<String>) {
49        let mut text = Text {
50            lines: vec![Line {
51                spans: vec![],
52                alignment: None,
53            }],
54        };
55        let mut links = vec![];
56        let mut out = RenderOutput::new(&mut text, &mut links);
57
58        for child in self.dom.children() {
59            self.render_internal(&mut out, child, Style::default());
60        }
61
62        (text, links)
63    }
64
65    /// Actual internal rendering function
66    fn render_internal(&self, out: &mut RenderOutput, handle: &NodeHandle, curr_style: Style) {
67        let node = handle.get(self.dom.parser()).unwrap();
68        match node {
69            Node::Tag(t) => {
70                let tag_name = &*t.name().as_utf8_str();
71                let c = t.children();
72                let children = c.top();
73                match tag_name {
74                    "br" => out.newline(),
75
76                    // Block text elements, which force their own line and may change the style
77                    "h4" | "h5" | "h6" | "div" | "p" => {
78                        let new_style = match tag_name {
79                            "h4" => curr_style
80                                .underline_color(Color::White)
81                                .add_modifier(Modifier::BOLD),
82                            "h5" | "h6" => curr_style.add_modifier(Modifier::BOLD),
83                            "div" | "p" => curr_style,
84                            _ => unreachable!(),
85                        };
86
87                        out.ensure_line_empty();
88                        for child in children.iter() {
89                            self.render_internal(out, child, new_style);
90                        }
91                        out.ensure_line_empty();
92                    }
93
94                    // Inline text elements, which at most change the style
95                    // td is here because we deal with it at the tr level (see further down)
96                    "span" | "strong" | "em" | "li" | "td" | "th" => {
97                        let new_style = match tag_name {
98                            "strong" => curr_style.add_modifier(Modifier::BOLD),
99                            "em" => curr_style.add_modifier(Modifier::ITALIC),
100                            _ => curr_style,
101                        };
102
103                        for child in children.iter() {
104                            self.render_internal(out, child, new_style);
105                        }
106                    }
107
108                    // Links
109                    "a" => {
110                        let new_style = curr_style.fg(Color::Blue);
111                        for child in children.iter() {
112                            self.render_internal(out, child, new_style);
113                        }
114                        if let Some(Some(b)) = t.attributes().get("href") {
115                            let href = b.as_utf8_str().to_string();
116                            let idx = out.add_link(href);
117
118                            out.append(Span::styled(format!("[{idx}]"), new_style));
119                        }
120                    }
121
122                    // Lists
123                    "ul" | "ol" => {
124                        // Function for getting next label
125                        let mut next_item: Box<dyn FnMut() -> String> = match tag_name {
126                            "ul" => Box::new(|| "  - ".to_string()),
127                            "ol" => {
128                                let mut i = 0;
129                                Box::new(move || {
130                                    i += 1;
131                                    format!("{}. ", i)
132                                })
133                            }
134                            _ => unreachable!(),
135                        };
136
137                        for child in children.iter() {
138                            // Render into new text object
139                            let mut subtext = Text::raw("");
140                            let mut suboutp = out.with_subtext(&mut subtext);
141                            let child_node = child.get(self.dom.parser()).unwrap();
142                            self.render_internal(&mut suboutp, child, curr_style);
143
144                            if suboutp.empty_or_whitespace() {
145                                continue;
146                            }
147
148                            match child_node {
149                                // Sublists don't use <li>s
150                                Node::Tag(t)
151                                    if t.name().as_utf8_str() == "ul"
152                                        || t.name().as_utf8_str() == "ol" =>
153                                {
154                                    // Remove padding
155                                    subtext.lines.remove(0);
156                                    subtext.lines.pop();
157                                    subtext.lines.pop();
158
159                                    // Don't use label, just indent further
160                                    for i in 0..subtext.lines.len() {
161                                        subtext.lines[i].spans.insert(0, Span::raw("  "));
162                                    }
163                                }
164                                _ => {
165                                    // Add label at top, and indent other lines
166                                    subtext.lines[0].spans.insert(0, Span::raw(next_item()));
167                                    for i in 1..subtext.lines.len() {
168                                        subtext.lines[i].spans.insert(0, Span::raw("    "));
169                                    }
170                                }
171                            };
172
173                            out.text.lines.extend(subtext.lines);
174                        }
175
176                        // padding
177                        out.ensure_line_empty();
178                        out.newline();
179                    }
180
181                    // Tables
182                    "table" => {
183                        // Render each cell
184                        let mut subtexts: Vec<Vec<Text<'static>>> = vec![];
185                        self.render_table_cells(out, t, &mut subtexts);
186
187                        debug!("{:?}", subtexts);
188
189                        // Ensure table is a square
190                        let max_cols = subtexts.iter().map(Vec::len).max().unwrap_or(0);
191                        subtexts
192                            .iter_mut()
193                            .for_each(|v| v.resize(max_cols, "".into()));
194
195                        // Figure out the dimensions of everything
196                        let mut col_widths = (0..max_cols)
197                            .map(|col_idx| {
198                                subtexts
199                                    .iter()
200                                    .map(|r| &r[col_idx])
201                                    .map(|t| t.width())
202                                    .max()
203                                    .unwrap_or(0)
204                            })
205                            .collect::<Vec<_>>();
206
207                        let total_width = col_widths.iter().sum::<usize>() + col_widths.len() + 1;
208                        let (widest_col_idx, &max_width) = col_widths
209                            .iter()
210                            .enumerate()
211                            .max_by_key(|(_, w)| **w)
212                            .unwrap_or((0, &0));
213                        // Attempt to shrink largest column if we need to
214                        if total_width > SCREEN_WIDTH && max_width > (total_width - SCREEN_WIDTH) {
215                            let new_width = max_width - (total_width - SCREEN_WIDTH);
216                            col_widths[widest_col_idx] = new_width;
217
218                            for row in subtexts.iter_mut() {
219                                wrap_text_to_width(&mut row[widest_col_idx], new_width);
220                            }
221                        }
222
223                        let row_heights = subtexts
224                            .iter()
225                            .map(|row| row.iter().map(|cell| cell.height()).max().unwrap_or(0))
226                            .collect::<Vec<_>>();
227
228                        // Now we can output our table with the right dimensions
229                        out.ensure_line_empty();
230
231                        out.append(table_vertical_border(
232                            &col_widths,
233                            TABLE_TOP_LEFT_BORDER,
234                            TABLE_VERTICAL_BORDER,
235                            TABLE_TOP_INTERSECT,
236                            TABLE_TOP_RIGHT_BORDER,
237                        ));
238                        let n_rows = subtexts.len();
239                        for (row_idx, row) in subtexts.into_iter().enumerate() {
240                            // append however many lines in this row to work with
241                            let row_height = row_heights[row_idx];
242                            let row_start_idx = out.text.lines.len();
243                            (0..row_height).for_each(|_| {
244                                out.text.lines.push(TABLE_HORIZ_BORDER.to_string().into())
245                            });
246
247                            for (col_idx, cell) in row.into_iter().enumerate() {
248                                let col_width = col_widths[col_idx];
249                                let added_to_lines = cell.lines.len();
250
251                                // add to the end of the existing lines, padding if needed
252                                for (line_idx, line) in cell.lines.into_iter().enumerate() {
253                                    let adding_width = line.width();
254                                    let add_to_line = &mut out.text.lines[row_start_idx + line_idx];
255                                    add_to_line.spans.extend(line.spans);
256                                    if adding_width < col_width {
257                                        add_to_line
258                                            .spans
259                                            .push(" ".repeat(col_width - adding_width).into());
260                                    }
261                                }
262
263                                // add space to the missing lines if needed
264                                for i in added_to_lines..row_height {
265                                    out.text.lines[row_start_idx + i]
266                                        .spans
267                                        .push(" ".repeat(col_width).into());
268                                }
269
270                                // add right borders
271                                (0..row_height).for_each(|i| {
272                                    out.text.lines[row_start_idx + i]
273                                        .spans
274                                        .push(TABLE_HORIZ_BORDER.to_string().into())
275                                });
276                            }
277
278                            if row_idx < n_rows - 1 {
279                                out.ensure_line_empty();
280                                out.append(table_vertical_border(
281                                    &col_widths,
282                                    TABLE_MID_LEFT_BORDER,
283                                    TABLE_VERTICAL_BORDER,
284                                    TABLE_MID_INTERSECT,
285                                    TABLE_MID_RIGHT_BORDER,
286                                ));
287                            }
288                        }
289
290                        out.ensure_line_empty();
291                        out.append(table_vertical_border(
292                            &col_widths,
293                            TABLE_BOT_LEFT_BORDER,
294                            TABLE_VERTICAL_BORDER,
295                            TABLE_BOT_INTERSECT,
296                            TABLE_BOT_RIGHT_BORDER,
297                        ));
298                    }
299
300                    // Gracefully degrade on unknown tags
301                    s => {
302                        log::error!("unknown tag: {}", s);
303                        t.children().top().iter().for_each(|child| {
304                            self.render_internal(
305                                out,
306                                child,
307                                curr_style.fg(Color::Red).underline_color(Color::Red),
308                            )
309                        })
310                    }
311                }
312            }
313            // Actual text
314            Node::Raw(s) => {
315                let mut text = String::with_capacity(s.as_utf8_str().len());
316                html_escape::decode_html_entities_to_string(
317                    collapse_whitespace(&s.as_utf8_str()),
318                    &mut text,
319                );
320                if !text.contains('\n') {
321                    out.append(Span::styled(text, curr_style));
322                } else {
323                    for l in text.split('\n') {
324                        out.append(Span::styled(l.to_string(), curr_style));
325                        out.newline();
326                    }
327                }
328            }
329            Node::Comment(_) => (),
330        }
331    }
332
333    fn render_table_cells(
334        &self,
335        out: &mut RenderOutput<'_>,
336        table: &HTMLTag<'_>,
337        cells: &mut Vec<Vec<Text<'static>>>,
338    ) {
339        for row_handle in table.children().top().iter() {
340            if let Node::Tag(row) = row_handle.get(self.dom.parser()).unwrap() {
341                match &*row.name().as_utf8_str() {
342                    "thead" | "tbody" => {
343                        self.render_table_cells(out, row, cells);
344                    }
345                    _ => {
346                        let mut cols = vec![];
347                        for cell in row.children().top().iter() {
348                            let mut subtext = Text::default();
349                            let mut suboutp = out.with_subtext(&mut subtext);
350                            self.render_internal(&mut suboutp, cell, Style::new());
351
352                            if subtext.width() == 0 || subtext.height() == 0 {
353                                continue;
354                            }
355                            cleanup(&mut subtext);
356                            cols.push(subtext);
357                        }
358                        if !cols.is_empty() {
359                            cells.push(cols);
360                        }
361                    }
362                }
363            }
364        }
365    }
366}
367
368fn wrap_text_to_width(text: &mut Text<'_>, new_width: usize) {
369    let mut i = 0;
370    while i < text.lines.len() {
371        if text.lines[i].width() > new_width {
372            let new_line = chop_after(&mut text.lines[i], new_width);
373            text.lines.insert(i + 1, new_line);
374        } else {
375            i += 1;
376        }
377    }
378}
379
380fn chop_after<'a>(line: &mut Line<'a>, width: usize) -> Line<'a> {
381    let mut cum_width = 0;
382    for i in 0..line.spans.len() {
383        if cum_width + line.spans[i].width() > width {
384            // split current span
385            let keep = width - cum_width;
386            let content = line.spans[i].content.clone();
387            line.spans[i].content = content.chars().take(keep).collect::<String>().into();
388
389            let mut new_line = vec![Span::styled(
390                content.chars().skip(keep).collect::<String>(),
391                line.spans[i].style,
392            )];
393            line.spans.drain(i + 1..).for_each(|s| new_line.push(s));
394            return new_line.into();
395        } else {
396            cum_width += line.spans[i].width();
397        }
398    }
399    vec![].into()
400}
401
402fn table_vertical_border(
403    col_widths: &[usize],
404    left: char,
405    straight: char,
406    intersect: char,
407    right: char,
408) -> Span<'static> {
409    let mut out = String::with_capacity(col_widths.iter().sum::<usize>() + col_widths.len() + 1);
410    out.push(left);
411    for (i, &col_width) in col_widths.iter().enumerate() {
412        (0..col_width).for_each(|_| out.push(straight));
413        if i < col_widths.len() - 1 {
414            out.push(intersect);
415        } else {
416            out.push(right);
417        }
418    }
419
420    out.into()
421}
422
423struct RenderOutput<'a> {
424    text: &'a mut Text<'static>,
425    links: &'a mut Vec<String>,
426}
427
428impl<'a> RenderOutput<'a> {
429    fn new(text: &'a mut Text<'static>, links: &'a mut Vec<String>) -> Self {
430        Self { text, links }
431    }
432
433    /// Add a newline to the text
434    fn newline(&mut self) {
435        self.text.lines.push(Line {
436            spans: vec![],
437            alignment: None,
438        });
439    }
440
441    /// Ensure that the last line of the text is empty
442    fn ensure_line_empty(&mut self) {
443        if !self.currline_empty() {
444            self.newline();
445        }
446    }
447    /// Append a span to the last line of the text
448    fn append(&mut self, span: Span<'static>) {
449        match self.text.lines.last_mut() {
450            Some(l) => l.spans.push(span),
451            None => self.text.lines.push(span.into()),
452        };
453    }
454
455    /// Check if the current line is empty
456    fn currline_empty(&mut self) -> bool {
457        self.text.lines.is_empty() || self.text.lines[self.text.lines.len() - 1].spans.is_empty()
458    }
459
460    /// Check if the given text is empty or only whitespace
461    fn empty_or_whitespace(&mut self) -> bool {
462        self.text
463            .lines
464            .iter()
465            .all(|l| l.spans.iter().all(|s| s.content.is_empty()))
466    }
467
468    /// Add a link to the encountered list, returning its index
469    fn add_link(&mut self, href: String) -> usize {
470        self.links.push(href);
471        self.links.len() - 1
472    }
473
474    fn with_subtext<'b>(&'b mut self, subtext: &'b mut Text<'static>) -> RenderOutput<'b>
475    where
476        'a: 'b,
477    {
478        RenderOutput {
479            text: subtext,
480            links: self.links,
481        }
482    }
483}
484
485/// Collapse all whitespace in a string
486fn collapse_whitespace(s: &str) -> String {
487    let s = s.trim();
488    let mut collapsed = String::with_capacity(s.len());
489    let mut last = ' ';
490
491    for c in s.chars() {
492        if c.is_whitespace() && last.is_whitespace() {
493            continue;
494        }
495
496        collapsed.push(c);
497        last = c;
498    }
499
500    collapsed
501}
502
503/// Cleans up text, removing empty spans and leading/trailing lines
504fn cleanup(text: &mut Text<'static>) {
505    text.lines
506        .iter_mut()
507        .for_each(|l| l.spans.retain(|s| !s.content.is_empty()));
508    if !text.lines.is_empty() && text.lines[0].spans.is_empty() {
509        text.lines.remove(0);
510    }
511
512    if !text.lines.is_empty() && text.lines.last().unwrap().spans.is_empty() {
513        text.lines.remove(text.lines.len() - 1);
514    }
515}