aboutsummaryrefslogtreecommitdiffstats
path: root/src/html.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/html.rs')
-rw-r--r--src/html.rs235
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 (`&#233;`, `&#xE9;`).
+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&#xE9;"), "café");
+ assert_eq!(decode_entities("a&#241;o"), "año");
+ assert_eq!(decode_entities("&laquo;hablar&raquo;"), "«hablar»");
+ assert_eq!(decode_entities("A &amp; 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);
+ }
+}