use ratatui_core::style::{Modifier, Style};
use ratatui_core::text::Span;
use tuika::style::{StyleSheet, Theme};
use crate::dom::{self, attr, tag};
use markup5ever_rcdom::{Handle, NodeData};
#[derive(Default)]
pub(crate) struct InlineBuf {
lines: Vec<Vec<Span<'static>>>,
}
impl InlineBuf {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn is_empty(&self) -> bool {
self.lines
.iter()
.all(|l| l.iter().all(|s| s.content.is_empty()))
}
pub(crate) fn take(&mut self) -> Vec<Vec<Span<'static>>> {
std::mem::take(&mut self.lines)
}
fn cur(&mut self) -> &mut Vec<Span<'static>> {
if self.lines.is_empty() {
self.lines.push(Vec::new());
}
self.lines.last_mut().expect("just ensured")
}
fn at_line_start(&self) -> bool {
match self.lines.last() {
None => true,
Some(line) => line.iter().all(|s| s.content.is_empty()),
}
}
fn push(&mut self, content: String, style: Style) {
if content.is_empty() {
return;
}
self.cur().push(Span::styled(content, style));
}
pub(crate) fn hard_break(&mut self) {
self.lines.push(Vec::new());
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Script {
Sub,
Sup,
}
impl Script {
fn map(self, ch: char) -> Option<char> {
let table = match self {
Script::Sub => "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎",
Script::Sup => "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾",
};
table.chars().nth("0123456789+-=()".find(ch)?)
}
}
fn transliterate(text: &str, script: Script) -> Option<String> {
if text.trim().is_empty() {
return None;
}
text.chars().map(|c| script.map(c)).collect()
}
pub(crate) fn collect(
node: &Handle,
style: Style,
theme: &Theme,
sheet: &StyleSheet,
depth: usize,
max_depth: usize,
buf: &mut InlineBuf,
) {
collect_scoped(node, style, None, theme, sheet, depth, max_depth, buf);
}
#[allow(clippy::too_many_arguments)]
fn collect_scoped(
node: &Handle,
style: Style,
script: Option<Script>,
theme: &Theme,
sheet: &StyleSheet,
depth: usize,
max_depth: usize,
buf: &mut InlineBuf,
) {
if depth > max_depth {
return;
}
match &node.data {
NodeData::Text { contents } => {
let text = dom::collapse(&dom::sanitize(&contents.borrow()));
let text = if buf.at_line_start() {
text.trim_start().to_string()
} else {
text
};
let text = script
.and_then(|script| transliterate(&text, script))
.unwrap_or(text);
buf.push(text, style);
}
NodeData::Element { .. } => {
let name = tag(node).unwrap_or_default();
if dom::is_dropped(&name) {
return;
}
match name.as_str() {
"br" => buf.hard_break(),
"img" => {
let alt = attr(node, "alt").unwrap_or_default();
let src = attr(node, "src").unwrap_or_default();
let label = if alt.trim().is_empty() { src } else { alt };
if !label.is_empty() {
buf.push("🖼 ".to_string(), sheet.image_marker.apply(style));
buf.push(dom::sanitize(&label), sheet.link.apply(style));
}
}
"wbr" => {}
_ => {
let inner = inline_style(&name, style, theme, sheet);
let script = match name.as_str() {
"sub" => Some(Script::Sub),
"sup" => Some(Script::Sup),
_ => script,
};
for child in node.children.borrow().iter() {
collect_scoped(
child,
inner,
script,
theme,
sheet,
depth + 1,
max_depth,
buf,
);
}
}
}
}
_ => {}
}
}
pub(crate) fn inline_style(name: &str, base: Style, theme: &Theme, sheet: &StyleSheet) -> Style {
match name {
"b" | "strong" => sheet.strong.apply(base),
"i" | "em" | "var" | "cite" | "dfn" | "address" | "figcaption" => {
sheet.emphasis.apply(base)
}
"code" | "kbd" | "samp" | "tt" => sheet.inline_code.apply(base),
"s" | "del" | "strike" => sheet.strikethrough.apply(base),
"u" | "ins" => base.add_modifier(Modifier::UNDERLINED),
"mark" => base.add_modifier(Modifier::REVERSED),
"a" => sheet.link.apply(base),
"small" => base.fg(theme.dim),
_ => base,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dom::parse;
fn spans(html: &str) -> Vec<(String, Style)> {
let theme = Theme::default();
let sheet = StyleSheet::from_theme(&theme);
let mut buf = InlineBuf::new();
let base = Style::default().fg(theme.text);
for child in parse(html).children.borrow().iter() {
collect(child, base, &theme, &sheet, 0, 32, &mut buf);
}
buf.take()
.into_iter()
.flatten()
.map(|s| (s.content.to_string(), s.style))
.collect()
}
fn text(html: &str) -> String {
spans(html).into_iter().map(|(c, _)| c).collect()
}
#[test]
fn whitespace_collapses_like_html() {
assert_eq!(text("<p>a \n b</p>"), "a b");
}
#[test]
fn nested_styles_compose() {
let out = spans("<b>bold <i>and italic</i></b>");
let italic = out
.iter()
.find(|(c, _)| c.contains("italic"))
.expect("italic span");
assert!(italic.1.add_modifier.contains(Modifier::BOLD));
assert!(italic.1.add_modifier.contains(Modifier::ITALIC));
}
#[test]
fn dropped_elements_take_their_content_with_them() {
assert_eq!(text("<p>keep<script>alert(1)</script></p>"), "keep");
assert_eq!(text("<style>body{color:red}</style>"), "");
}
#[test]
fn unknown_elements_stay_transparent() {
assert_eq!(text("<custom-thing>visible</custom-thing>"), "visible");
}
#[test]
fn images_fall_back_to_their_alt_text() {
assert!(text("<img src=p.png alt='a cat'>").contains("a cat"));
assert!(text("<img src=p.png>").contains("p.png"));
}
#[test]
fn sub_and_sup_become_unicode_when_every_character_maps() {
assert_eq!(text("H<sub>2</sub>O"), "H₂O");
assert_eq!(text("2<sup>10</sup>"), "2¹⁰");
assert_eq!(text("x<sup>-9</sup>"), "x⁻⁹");
assert_eq!(text("4<sup>th</sup>"), "4th");
}
#[test]
fn control_bytes_never_reach_a_span() {
assert_eq!(text("<p>a\u{1b}[31mb</p>"), "a[31mb");
}
}