//! Minimal HTML utilities: entity decoding, tag stripping and element
//! extraction with depth counting.
//!
//! The DLE markup is regular, so a full parser would be overkill. But each
//! sense (`
`) contains its own synonym list
//! (`
…
`), so cutting "up to the
//! next `
`" returns a truncated fragment. Hence [`balanced`], which
//! counts openings and closings.
/// Decodes HTML entities: common named ones and numeric ones (`é`, `é`).
pub fn decode_entities(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'&' {
// Copy the whole UTF-8 character, not a lone byte.
let ch = s[i..].chars().next().unwrap();
out.push(ch);
i += ch.len_utf8();
continue;
}
match s[i..].find(';').filter(|&e| e <= 12) {
Some(end) => {
let ent = &s[i + 1..i + end];
match named_or_numeric(ent) {
Some(ch) => {
out.push(ch);
i += end + 1;
}
None => {
out.push('&');
i += 1;
}
}
}
None => {
out.push('&');
i += 1;
}
}
}
out
}
fn named_or_numeric(ent: &str) -> Option {
if let Some(num) = ent.strip_prefix('#') {
let code = match num.strip_prefix('x').or_else(|| num.strip_prefix('X')) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => num.parse::().ok()?,
};
return char::from_u32(code);
}
Some(match ent {
"amp" => '&',
"lt" => '<',
"gt" => '>',
"quot" => '"',
"apos" | "#39" => '\'',
"nbsp" => ' ',
"iexcl" => '¡',
"iquest" => '¿',
"laquo" => '«',
"raquo" => '»',
"ldquo" => '“',
"rdquo" => '”',
"hellip" => '…',
"mdash" => '—',
"ndash" => '–',
"deg" => '°',
_ => return None,
})
}
/// Removes tags, decodes entities and normalizes whitespace.
pub fn strip_tags(s: &str) -> String {
let mut raw = String::with_capacity(s.len());
let mut depth = 0usize;
for ch in s.chars() {
match ch {
'<' => depth += 1,
'>' => depth = depth.saturating_sub(1),
_ if depth == 0 => raw.push(ch),
_ => {}
}
}
collapse_ws(&decode_entities(&raw))
}
/// Collapses repeated whitespace and trims, without breaking the thin spaces
/// the DLE uses before punctuation.
pub fn collapse_ws(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut space = false;
for ch in s.chars() {
if ch.is_whitespace() {
space = !out.is_empty();
} else {
if space {
out.push(' ');
}
space = false;
out.push(ch);
}
}
out
}
/// Index of the next `) -> Option {
let pat = format!("<{tag}");
let mut i = from;
while let Some(rel) = h[i..].find(&pat) {
let start = i + rel;
let after = start + pat.len();
// Keep `
' || c == '/');
if ok {
let end = start + h[start..].find('>')? + 1;
match needle {
Some(n) if !h[start..end].contains(n) => i = end,
_ => return Some(start),
}
} else {
i = after;
}
}
None
}
/// Returns the whole element starting at `start`, counting nested tags with
/// the same name. Includes the closing tag.
pub fn balanced<'a>(h: &'a str, start: usize, tag: &str) -> &'a str {
let open = format!("<{tag}");
let close = format!("{tag}");
let mut depth = 0usize;
let mut i = start;
while i < h.len() {
let next_open = find_open(h, i, tag, None);
let next_close = h[i..].find(&close).map(|r| i + r);
match (next_open, next_close) {
(Some(o), Some(c)) if o < c => {
depth += 1;
i = o + open.len();
}
(_, Some(c)) => {
if depth == 0 {
break;
}
depth -= 1;
let end = h[c..].find('>').map(|e| c + e + 1).unwrap_or(h.len());
if depth == 0 {
return &h[start..end];
}
i = end;
}
(Some(o), None) => i = o + open.len(),
(None, None) => break,
}
}
&h[start..]
}
/// Reads an attribute value inside an opening tag.
pub fn attr(open_tag: &str, name: &str) -> Option {
let pat = format!("{name}=\"");
let i = open_tag.find(&pat)? + pat.len();
let j = open_tag[i..].find('"')? + i;
Some(decode_entities(&open_tag[i..j]))
}
/// The opening tag that starts at `start` (`
`).
pub fn open_tag(h: &str, start: usize) -> &str {
match h[start..].find('>') {
Some(e) => &h[start..start + e + 1],
None => &h[start..],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_named_and_numeric_entities() {
assert_eq!(decode_entities("café"), "café");
assert_eq!(decode_entities("año"), "año");
assert_eq!(decode_entities("«hablar»"), "«hablar»");
assert_eq!(decode_entities("A & B"), "A & B");
}
#[test]
fn leaves_lone_ampersands_alone() {
assert_eq!(decode_entities("tal & cual"), "tal & cual");
assert_eq!(decode_entities("&noesunaentidad;"), "&noesunaentidad;");
}
#[test]
fn strips_tags_and_collapses_whitespace() {
let h = r#"
1. f. Edificio.
"#;
assert_eq!(strip_tags(h), "1. f. Edificio.");
}
#[test]
fn balanced_handles_nested_same_tag() {
// Exactly the DLE case: the synonym list lives inside the sense, so
// there is an inner `
`.
let h = r#"
def
sinónimo
otra
"#;
let start = find_open(h, 0, "li", Some(r#"class="j""#)).unwrap();
let el = balanced(h, start, "li");
assert!(el.ends_with("
"));
assert!(el.contains("sinónimo"));
assert!(!el.contains("otra"), "ran into the next sense: {el}");
}
#[test]
fn find_open_does_not_match_tag_prefixes() {
let h = r#"
ok
"#;
let start = find_open(h, 0, "li", None).unwrap();
assert!(h[start..].starts_with(r#"