pub(crate) fn html_to_text(s: &str) -> String {
let after_tags = strip_tags(s);
let unescaped = unescape_entities(&after_tags);
let sanitised = strip_control_chars(&unescaped);
let collapsed = collapse_newlines(&sanitised);
collapsed.trim().to_string()
}
pub(crate) fn strip_control_chars(s: &str) -> String {
s.chars()
.filter(|&c| {
let n = c as u32;
!(n <= 0x1F && n != 0x0A && n != 0x09) && n != 0x7F
})
.collect()
}
pub(crate) fn replace_control_chars(s: &str) -> String {
s.chars()
.map(|c| if c.is_ascii_control() { ' ' } else { c })
.collect()
}
fn strip_tags(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i] == b'<' {
let tag_start = i;
i += 1; let mut j = i;
let mut in_quote: Option<u8> = None;
while j < len {
let b = bytes[j];
match in_quote {
Some(q) if b == q => {
in_quote = None;
j += 1;
}
Some(_) => {
j += 1;
}
None if b == b'"' || b == b'\'' => {
in_quote = Some(b);
j += 1;
}
None if b == b'>' => {
j += 1; break;
}
None => {
j += 1;
}
}
}
let raw_tag = &s[tag_start..j];
let inner = if raw_tag.starts_with('<') && raw_tag.ends_with('>') {
&raw_tag[1..raw_tag.len() - 1]
} else {
out.push_str(raw_tag);
i = j;
continue;
};
let tag_name_lower = tag_name_of(inner).to_ascii_lowercase();
match tag_name_lower.as_str() {
"br" => out.push('\n'),
"p" => out.push('\n'),
"/p" => out.push('\n'),
"a" => {
let href = extract_href(inner);
let (text, consumed) = collect_until_close_tag(&s[j..], "a");
let plain_text = strip_tags(text);
if plain_text.is_empty() {
if let Some(url) = href {
out.push_str(url);
}
} else {
out.push_str(&plain_text);
if let Some(url) = href {
out.push_str(" (");
out.push_str(url);
out.push(')');
}
}
i = j + consumed;
continue;
}
_ => {
}
}
i = j;
} else {
match s[i..].chars().next() {
Some(ch) => {
out.push(ch);
i += ch.len_utf8();
}
None => break,
}
}
}
out
}
fn tag_name_of(inner: &str) -> String {
let trimmed = inner.trim();
let token = trimmed.split_whitespace().next().unwrap_or("");
token.trim_end_matches('/').to_string()
}
fn collect_until_close_tag<'a>(rest: &'a str, tag: &str) -> (&'a str, usize) {
let close_needle = format!("</{tag}");
let lower = rest.to_ascii_lowercase();
if let Some(pos) = lower.find(&close_needle) {
let after_name = pos + close_needle.len();
let close_end = rest[after_name..]
.find('>')
.map(|p| after_name + p + 1)
.unwrap_or(rest.len());
(&rest[..pos], close_end)
} else {
(rest, rest.len())
}
}
fn extract_href(inner: &str) -> Option<&str> {
let lower = inner.to_ascii_lowercase();
let href_pos = lower.find("href")?;
let after_href = inner[href_pos + 4..].trim_start();
let rest = after_href.strip_prefix('=')?;
let rest = rest.trim_start();
let (quote_char, value_start) = if let Some(s) = rest.strip_prefix('"') {
('"', s)
} else if let Some(s) = rest.strip_prefix('\'') {
('\'', s)
} else {
return None;
};
let end = value_start.find(quote_char)?;
Some(&value_start[..end])
}
fn unescape_entities(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '&' {
let mut entity = String::new();
let mut found_semi = false;
for _ in 0..12 {
match chars.peek() {
Some(&';') => {
chars.next();
found_semi = true;
break;
}
Some(_) => {
if let Some(c) = chars.next() {
entity.push(c);
}
}
None => break,
}
}
if found_semi {
match entity.as_str() {
"amp" => out.push('&'),
"lt" => out.push('<'),
"gt" => out.push('>'),
"quot" => out.push('"'),
"apos" | "#39" => out.push('\''),
"nbsp" => out.push(' '),
_ => {
out.push('&');
out.push_str(&entity);
out.push(';');
}
}
} else {
out.push('&');
out.push_str(&entity);
}
} else {
out.push(ch);
}
}
out
}
fn collapse_newlines(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut newline_run: usize = 0;
for ch in s.chars() {
if ch == '\n' {
newline_run += 1;
if newline_run <= 2 {
out.push('\n');
}
} else {
newline_run = 0;
out.push(ch);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_text_passthrough() {
let s = "Hello, world!";
assert_eq!(html_to_text(s), s);
}
#[test]
fn plain_text_trimmed() {
assert_eq!(html_to_text(" hello "), "hello");
assert_eq!(html_to_text("\nhello\n"), "hello");
}
#[test]
fn br_tag_to_newline() {
assert_eq!(html_to_text("a<br>b"), "a\nb");
assert_eq!(html_to_text("a<br/>b"), "a\nb");
assert_eq!(html_to_text("a<br />b"), "a\nb");
assert_eq!(html_to_text("a<BR>b"), "a\nb");
assert_eq!(html_to_text("a<Br />b"), "a\nb");
}
#[test]
fn p_tag_to_newline() {
assert_eq!(html_to_text("<p>Hello</p>"), "Hello");
let result = html_to_text("<p>A</p><p>B</p>");
assert!(result.contains('A'));
assert!(result.contains('B'));
}
#[test]
fn anchor_with_href() {
assert_eq!(
html_to_text("<a href=\"https://example.com\">Click here</a>"),
"Click here (https://example.com)"
);
}
#[test]
fn anchor_with_single_quote_href() {
assert_eq!(
html_to_text("<a href='https://example.com'>text</a>"),
"text (https://example.com)"
);
}
#[test]
fn anchor_without_href() {
assert_eq!(html_to_text("<a name=\"top\">Section</a>"), "Section");
}
#[test]
fn anchor_empty_text_with_href() {
assert_eq!(
html_to_text("<a href=\"https://example.com\"></a>"),
"https://example.com"
);
}
#[test]
fn bold_and_italic_stripped() {
assert_eq!(html_to_text("<b>bold</b>"), "bold");
assert_eq!(html_to_text("<i>italic</i>"), "italic");
assert_eq!(html_to_text("<strong>strong</strong>"), "strong");
assert_eq!(html_to_text("<em>em</em>"), "em");
}
#[test]
fn unknown_tags_stripped() {
assert_eq!(html_to_text("<ul><li>item</li></ul>"), "item");
assert_eq!(html_to_text("<span class=\"x\">text</span>"), "text");
}
#[test]
fn entity_unescaping() {
assert_eq!(html_to_text("a&b"), "a&b");
assert_eq!(html_to_text("a<b"), "a<b");
assert_eq!(html_to_text("a>b"), "a>b");
assert_eq!(html_to_text("say "hello""), "say \"hello\"");
assert_eq!(html_to_text("it's"), "it's");
assert_eq!(html_to_text("it's"), "it's");
assert_eq!(html_to_text("a b"), "a b");
assert_eq!(html_to_text("a&unknown;b"), "a&unknown;b");
}
#[test]
fn control_char_and_ansi_escape_stripped() {
let input = "a\u{1b}[31mred\u{1b}[0mb";
let result = html_to_text(input);
assert!(
!result.contains('\u{1b}'),
"ESC must be stripped; got: {result:?}"
);
assert!(result.contains('a'));
assert!(result.contains('b'));
}
#[test]
fn null_byte_stripped() {
let input = "hel\0lo";
assert_eq!(html_to_text(input), "hello");
}
#[test]
fn del_byte_stripped() {
let input = "hel\u{7f}lo";
assert_eq!(html_to_text(input), "hello");
}
#[test]
fn newline_preserved_tab_preserved() {
assert_eq!(html_to_text("a\nb"), "a\nb");
assert_eq!(html_to_text("a\tb"), "a\tb");
}
#[test]
fn blank_line_collapsing() {
assert_eq!(html_to_text("a\n\n\nb"), "a\n\nb");
assert_eq!(html_to_text("a\n\n\n\n\nb"), "a\n\nb");
assert_eq!(html_to_text("a\n\nb"), "a\n\nb");
}
#[test]
fn beol_metadata_block() {
let input = concat!(
"Project Metadata:<br/>",
"View Metadata (<a href=\"https://meta.dasch.swiss/projects/0801\">",
"https://meta.dasch.swiss/projects/0801</a>)",
"<br/><br/>",
"Dataset License:<br/>",
"CC BY-NC-SA 4.0 (<a href=\"https://creativecommons.org/licenses/by-nc-sa/4.0/\">",
"https://creativecommons.org/licenses/by-nc-sa/4.0/</a>)",
"<br/><br/>",
"The Bernoulli-Euler Online (BEOL) project is a research platform."
);
let result = html_to_text(input);
assert!(!result.contains('<'), "no raw tags; got: {result:?}");
assert!(!result.contains('>'), "no raw tags; got: {result:?}");
assert!(
result.contains(
"https://meta.dasch.swiss/projects/0801 (https://meta.dasch.swiss/projects/0801)"
),
"link rendered as TEXT (URL); got: {result:?}"
);
assert!(
result.contains("The Bernoulli-Euler Online (BEOL) project"),
"body text present; got: {result:?}"
);
}
#[test]
fn simple_bold_description() {
let result = html_to_text("<b>BEOL</b> \u{2014} early modern mathematics.");
assert_eq!(result, "BEOL \u{2014} early modern mathematics.");
}
#[test]
fn nested_formatting() {
let result = html_to_text("<p><strong>Title</strong>: text & more</p>");
assert!(!result.contains('<'), "no tags; got: {result:?}");
assert!(result.contains("Title"), "title present; got: {result:?}");
assert!(
result.contains("text & more"),
"entity unescaped; got: {result:?}"
);
}
#[test]
fn href_in_larger_tag_attrs() {
let result = html_to_text("<a class=\"foo\" href=\"https://x.com\">link text</a>");
assert_eq!(result, "link text (https://x.com)");
}
#[test]
fn strip_control_chars_keeps_printable() {
assert_eq!(strip_control_chars("hello world"), "hello world");
}
#[test]
fn strip_control_chars_keeps_newline_and_tab() {
assert_eq!(strip_control_chars("a\nb"), "a\nb");
assert_eq!(strip_control_chars("a\tb"), "a\tb");
}
#[test]
fn strip_control_chars_removes_c0_and_del() {
assert_eq!(strip_control_chars("a\x00b"), "ab");
assert_eq!(strip_control_chars("a\x1bb"), "ab");
assert_eq!(strip_control_chars("a\x7fb"), "ab");
}
#[test]
fn replace_control_chars_plain_string_unchanged() {
assert_eq!(replace_control_chars("hello world"), "hello world");
}
#[test]
fn replace_control_chars_tab_replaced() {
assert_eq!(replace_control_chars("a\tb"), "a b");
}
#[test]
fn replace_control_chars_newline_replaced() {
assert_eq!(replace_control_chars("a\nb"), "a b");
}
#[test]
fn replace_control_chars_cr_replaced() {
assert_eq!(replace_control_chars("a\rb"), "a b");
}
#[test]
fn replace_control_chars_nul_replaced() {
assert_eq!(replace_control_chars("a\x00b"), "a b");
}
#[test]
fn replace_control_chars_esc_replaced() {
assert_eq!(replace_control_chars("a\x1bb"), "a b");
}
#[test]
fn replace_control_chars_del_replaced() {
assert_eq!(replace_control_chars("a\x7fb"), "a b");
}
#[test]
fn replace_control_chars_multibyte_unicode_unchanged() {
assert_eq!(replace_control_chars("héllo wörld"), "héllo wörld");
assert_eq!(replace_control_chars("日本語"), "日本語");
}
#[test]
fn replace_control_chars_printable_ascii_unchanged() {
assert_eq!(replace_control_chars(" "), " ");
assert_eq!(replace_control_chars("~"), "~");
}
#[test]
fn replace_control_chars_multiple_controls() {
assert_eq!(replace_control_chars("a\t\nb\rc"), "a b c");
}
}