diff options
Diffstat (limited to 'src/html.rs')
| -rw-r--r-- | src/html.rs | 235 |
1 files changed, 235 insertions, 0 deletions
diff --git a/src/html.rs b/src/html.rs new file mode 100644 index 0000000..1522610 --- /dev/null +++ b/src/html.rs @@ -0,0 +1,235 @@ +//! 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 (`<li class="j">`) contains its own synonym list +//! (`<ul class="c-word-list__items"><li>…</li></ul>`), so cutting "up to the +//! next `</li>`" 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<char> { + 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::<u32>().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 `<tag` whose opening tag contains `needle` (for example +/// `class="j"`), searching from `from`. +pub fn find_open(h: &str, from: usize, tag: &str, needle: Option<&str>) -> Option<usize> { + 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 `<li` from matching `<link`: the name must be followed by a + // separator or the end of the tag. + let ok = h[after..] + .chars() + .next() + .is_some_and(|c| c.is_whitespace() || c == '>' || 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<String> { + 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` (`<li id="x" class="j">`). +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#"<div><span class="n_acep">1. </span> <abbr>f.</abbr> Edificio.</div>"#; + 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 `</li>`. + let h = r#"<li class="j">def<ul><li>sinónimo</li></ul></li><li class="j">otra</li>"#; + let start = find_open(h, 0, "li", Some(r#"class="j""#)).unwrap(); + let el = balanced(h, start, "li"); + assert!(el.ends_with("</li>")); + 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#"<link rel="x"><li class="j">ok</li>"#; + let start = find_open(h, 0, "li", None).unwrap(); + assert!(h[start..].starts_with(r#"<li class="j""#)); + } + + #[test] + fn reads_attributes() { + let t = r#"<abbr class="d" title="nombre femenino">"#; + 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); + } +} |