//! 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!(" { 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#"
  • "#; assert_eq!(attr(t, "title").as_deref(), Some("nombre femenino")); assert_eq!(attr(t, "class").as_deref(), Some("d")); assert_eq!(attr(t, "id"), None); } }