const DROPPED_ELEMENTS: [&str; 2] = ["script", "style"];
const LINE_BREAKING: [&str; 21] = [
"br",
"p",
"div",
"tr",
"li",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"table",
"thead",
"tbody",
"tfoot",
"ul",
"ol",
"blockquote",
"hr",
"section",
"article",
];
const WORD_SEPARATING: [&str; 2] = ["td", "th"];
pub(super) fn html_block_to_lines(raw: &str) -> Vec<String> {
let chars: Vec<char> = raw.chars().collect();
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
let mut skip_until: Option<String> = None;
let mut i = 0;
while i < chars.len() {
if chars[i] == '<' {
if let Some(next) = skip_comment(&chars, i) {
i = next;
continue;
}
let Some((inner, next)) = read_tag(&chars, i) else {
push_char(&mut cur, '<');
i += 1;
continue;
};
i = next;
apply_tag(&inner, &mut cur, &mut out, &mut skip_until);
continue;
}
if skip_until.is_none() {
push_char(&mut cur, chars[i]);
}
i += 1;
}
flush(&mut cur, &mut out);
out
}
fn apply_tag(
inner: &str,
cur: &mut String,
out: &mut Vec<String>,
skip_until: &mut Option<String>,
) {
let tag = Tag::parse(inner);
if let Some(open) = &*skip_until {
if tag.closing && tag.name == *open {
*skip_until = None;
}
return;
}
if !tag.closing && !tag.self_closing && DROPPED_ELEMENTS.contains(&tag.name.as_str()) {
*skip_until = Some(tag.name);
return;
}
if tag.name == "img" && !tag.closing {
push_image(cur, inner);
return;
}
if LINE_BREAKING.contains(&tag.name.as_str()) {
flush(cur, out);
} else if WORD_SEPARATING.contains(&tag.name.as_str()) {
push_char(cur, ' ');
}
}
pub(super) fn is_break_only(raw: &str) -> bool {
let mut seen_break = false;
let chars: Vec<char> = raw.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '<' {
if let Some(next) = skip_comment(&chars, i) {
i = next;
continue;
}
if let Some((inner, next)) = read_tag(&chars, i) {
i = next;
seen_break |= LINE_BREAKING.contains(&Tag::parse(&inner).name.as_str());
continue;
}
return false;
}
if !chars[i].is_whitespace() {
return false;
}
i += 1;
}
seen_break
}
fn push_image(cur: &mut String, inner: &str) {
if let Some(alt) = attr(inner, "alt").filter(|a| !a.trim().is_empty()) {
for c in decode_entities(&alt).chars() {
push_char(cur, c);
}
}
if let Some(src) = attr(inner, "src").filter(|s| !s.trim().is_empty()) {
push_char(cur, ' ');
cur.push('(');
cur.push_str(decode_entities(&src).trim());
cur.push(')');
}
}
fn push_char(cur: &mut String, c: char) {
if c.is_whitespace() {
if !cur.is_empty() && !cur.ends_with(' ') {
cur.push(' ');
}
} else {
cur.push(c);
}
}
fn flush(cur: &mut String, out: &mut Vec<String>) {
let line = std::mem::take(cur);
let line = line.trim();
if !line.is_empty() {
out.push(decode_entities(line));
}
}
fn skip_comment(chars: &[char], at: usize) -> Option<usize> {
if chars[at..].starts_with(&['<', '!', '-', '-']) {
let mut i = at + 4;
while i + 2 < chars.len() {
if chars[i] == '-' && chars[i + 1] == '-' && chars[i + 2] == '>' {
return Some(i + 3);
}
i += 1;
}
return Some(chars.len());
}
None
}
fn read_tag(chars: &[char], at: usize) -> Option<(String, usize)> {
let mut buf = String::new();
let mut quote: Option<char> = None;
for (i, &c) in chars.iter().enumerate().skip(at + 1) {
match quote {
Some(q) => {
if c == q {
quote = None;
}
buf.push(c);
}
None if c == '"' || c == '\'' => {
quote = Some(c);
buf.push(c);
}
None if c == '>' => return Some((buf, i + 1)),
None => buf.push(c),
}
}
None
}
struct Tag {
name: String,
closing: bool,
self_closing: bool,
}
impl Tag {
fn parse(inner: &str) -> Self {
let trimmed = inner.trim();
let closing = trimmed.starts_with('/');
let name: String = trimmed
.trim_start_matches('/')
.chars()
.take_while(char::is_ascii_alphanumeric)
.collect();
Self {
name: name.to_ascii_lowercase(),
closing,
self_closing: trimmed.ends_with('/'),
}
}
}
fn attr(inner: &str, name: &str) -> Option<String> {
let lower = inner.to_ascii_lowercase();
let mut from = 0;
while let Some(pos) = lower[from..].find(name) {
let at = from + pos;
from = at + name.len();
let before_ok = at > 0 && lower[..at].ends_with(|c: char| c.is_whitespace());
let rest = lower[from..].trim_start();
if !before_ok || !rest.starts_with('=') {
continue;
}
let value_at = from + lower[from..].find('=')? + 1;
let value = inner[value_at..].trim_start();
let mut ch = value.chars();
return match ch.next() {
Some(q @ ('"' | '\'')) => value[1..].split(q).next().map(str::to_string),
Some(_) => value
.split(|c: char| c.is_whitespace())
.next()
.map(str::to_string),
None => None,
};
}
None
}
fn decode_entities(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(at) = rest.find('&') {
out.push_str(&rest[..at]);
rest = &rest[at..];
let Some(end) = rest.find(';').filter(|e| *e <= 12) else {
out.push('&');
rest = &rest[1..];
continue;
};
let name = &rest[1..end];
let decoded = match name.to_ascii_lowercase().as_str() {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" | "#39" => Some('\''),
"nbsp" => Some(' '),
_ => numeric_entity(name),
};
match decoded {
Some(c) => {
out.push(c);
rest = &rest[end + 1..];
}
None => {
out.push('&');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
fn numeric_entity(name: &str) -> Option<char> {
let digits = name.strip_prefix('#')?;
let code = match digits.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => digits.parse::<u32>().ok()?,
};
char::from_u32(code)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_table_block_yields_its_prose() {
let lines = html_block_to_lines(
"<table>\n<tr><td>start with clarity</td><td>and why it matters</td></tr>\n\
<tr><td>break your vision</td><td>into phases</td></tr>\n</table>",
);
assert_eq!(
lines,
vec![
"start with clarity and why it matters",
"break your vision into phases"
],
"cells join with a space, rows break the line"
);
}
#[test]
fn script_and_style_content_is_dropped() {
let lines = html_block_to_lines(
"<div><style>.a { color: red; }</style>visible<script>alert(1)</script></div>",
);
assert_eq!(lines, vec!["visible"]);
}
#[test]
fn a_dropped_element_ends_only_on_its_own_closing_tag() {
let lines = html_block_to_lines("<style>a { b: c }</p>still css</style>after");
assert_eq!(lines, vec!["after"]);
}
#[test]
fn attributes_never_reach_the_output() {
let lines = html_block_to_lines(
"<td width=\"50%\" style=\"background-color: white;\">real prose</td>",
);
assert_eq!(lines, vec!["real prose"]);
}
#[test]
fn a_quoted_angle_bracket_does_not_end_the_tag() {
let lines = html_block_to_lines("<td title=\"a > b\">text</td>");
assert_eq!(lines, vec!["text"]);
}
#[test]
fn comments_are_dropped_whole() {
let lines = html_block_to_lines("<div>before<!-- a > b, hidden -->after</div>");
assert_eq!(lines, vec!["beforeafter"]);
assert_eq!(html_block_to_lines("<div>x<!-- unterminated"), vec!["x"]);
}
#[test]
fn an_image_prints_alt_and_url_like_a_markdown_image() {
let lines = html_block_to_lines(
"<img src=\"chaos.svg\" alt=\"chaos - without structure\" style=\"width: 500px\">",
);
assert_eq!(lines, vec!["chaos - without structure (chaos.svg)"]);
assert_eq!(html_block_to_lines("<img src=\"a.png\">"), vec!["(a.png)"]);
assert!(html_block_to_lines("<img>").is_empty());
}
#[test]
fn attr_matches_whole_names_only() {
assert_eq!(
attr("img datasrc=\"x\" src=\"y\"", "src").as_deref(),
Some("y")
);
assert_eq!(
attr("img src=bare.png alt='q'", "src").as_deref(),
Some("bare.png")
);
assert_eq!(attr("img alt='q'", "src"), None);
}
#[test]
fn entities_are_decoded_and_unknown_ones_survive() {
assert_eq!(decode_entities("a & b <c>"), "a & b <c>");
assert_eq!(decode_entities("'xA"), "'xA");
assert_eq!(decode_entities(" gap"), " gap");
assert_eq!(
decode_entities("… &unknown; plain &"),
"… &unknown; plain &",
"an unknown entity is shown, not swallowed"
);
}
#[test]
fn whitespace_is_collapsed_and_blank_lines_dropped() {
let lines = html_block_to_lines("<div>\n a\n\n b c\n</div>\n\n<div>\n</div>");
assert_eq!(lines, vec!["a b c"]);
}
#[test]
fn malformed_markup_degrades_to_text() {
assert_eq!(html_block_to_lines("a < b and 5<6"), vec!["a < b and 5<6"]);
assert_eq!(html_block_to_lines("<td>unclosed"), vec!["unclosed"]);
assert!(html_block_to_lines("<div></div>").is_empty());
}
#[test]
fn is_break_only_recognizes_a_lone_break() {
assert!(is_break_only("<br>"));
assert!(is_break_only(" <br />\n"));
assert!(is_break_only("<hr>"));
assert!(!is_break_only("<br>text"));
assert!(!is_break_only("<td></td>"), "no break tag at all");
assert!(!is_break_only("<img src=\"a\">"));
}
}