const MAX_PASSES: usize = 4;
pub fn plain_text(html: &str) -> String {
let mut text = html.to_string();
for _ in 0..MAX_PASSES {
let next = decode_entities(&strip_tags(&text));
if next == text {
break;
}
text = next;
}
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn truncate(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let cut: String = text.chars().take(max).collect();
let cut = match cut.rfind(' ') {
Some(i) if i > max / 2 => &cut[..i],
_ => cut.as_str(),
};
format!("{}…", cut.trim_end_matches([',', ';', ':', '.', ' ']))
}
fn strip_tags(html: &str) -> String {
let mut out = String::with_capacity(html.len());
let mut rest = html;
while let Some(start) = rest.find('<') {
out.push_str(&rest[..start]);
let tag = &rest[start..];
let Some(end) = tag.find('>') else {
out.push_str(tag);
return out;
};
let name: String = tag[1..end]
.trim_start_matches('/')
.chars()
.take_while(|c| c.is_ascii_alphanumeric())
.collect::<String>()
.to_ascii_lowercase();
rest = &tag[end + 1..];
if (name == "script" || name == "style") && !tag[1..].starts_with('/') {
let close = format!("</{name}");
rest = match rest.to_ascii_lowercase().find(&close) {
Some(i) => rest[i..].find('>').map_or("", |j| &rest[i + j + 1..]),
None => "",
};
}
out.push(' ');
}
out.push_str(rest);
out
}
fn decode_entities(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find('&') {
out.push_str(&rest[..start]);
let entity = &rest[start..];
let decoded = entity.find(';').filter(|&end| end <= 10).and_then(|end| {
let name = &entity[1..end];
let ch = match name {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
"nbsp" => Some(' '),
"ndash" => Some('–'),
"mdash" => Some('—'),
"hellip" => Some('…'),
"lsquo" => Some('‘'),
"rsquo" => Some('’'),
"ldquo" => Some('“'),
"rdquo" => Some('”'),
_ => name
.strip_prefix("#x")
.or_else(|| name.strip_prefix("#X"))
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
.or_else(|| name.strip_prefix('#').and_then(|dec| dec.parse().ok()))
.and_then(char::from_u32),
}?;
Some((ch, end))
});
match decoded {
Some((ch, end)) => {
out.push(ch);
rest = &entity[end + 1..];
}
None => {
out.push('&');
rest = &entity[1..];
}
}
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_abstract_escaped_twice_comes_out_as_text() {
let abstract_ = "<strong> Cite as: </strong> Geoffrey Bilder (2025, April 2). \
<i> Bots Behaving Badly </i> . Retrieved from https://doi.org/10.59347/w366z-egx85 \
navigator.clipboard.writeText(t).then(() =&gt; { alert('copied') })";
assert_eq!(
plain_text(abstract_),
"Cite as: Geoffrey Bilder (2025, April 2). Bots Behaving Badly . Retrieved from \
https://doi.org/10.59347/w366z-egx85 navigator.clipboard.writeText(t).then(() => { alert('copied') })"
);
}
#[test]
fn tags_go_and_scripts_go_with_their_contents() {
assert_eq!(
plain_text(
"<p>One<br/>two</p><script type=\"text/javascript\">alert('x <b>')</script><STYLE>p{}</STYLE> three"
),
"One two three"
);
assert_eq!(plain_text("x < y and a & b"), "x < y and a & b");
assert_eq!(
plain_text("“quoted” &amp; café"),
"“quoted” & café".replace("é", "é")
);
}
#[test]
fn truncate_cuts_at_a_word_and_marks_the_cut() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("one two three four", 12), "one two…");
assert_eq!(truncate("one, two, three", 9), "one, two…");
}
}