//! Turning HTML character references back into the characters they name. //! //! Converted markdown carries doubly-escaped markup: a tag that was escaped //! once by the source and once by the converter arrives as `<sup>107`, //! which no tag pattern can see. Decoding first turns it into a real tag that //! the stripping pass then removes, and fixes `&` and the numeric forms //! in ordinary prose on the way past. //! //! The rules are the specification's, including the parts that exist only //! because browsers had to accept what was already on the web: a reference //! with no closing semicolon, a name that is the prefix of a longer name, and //! numbers that mean Windows-1252 positions rather than code points. use std::sync::LazyLock; use regex::Regex; use crate::entities::{INVALID_CHARREFS, INVALID_CODEPOINTS, NAMED}; /// A character reference: a number, a hexadecimal number, or a name of up to /// thirty-two characters, each with an optional closing semicolon. static CHARREF: LazyLock = LazyLock::new(|| { Regex::new(r"&(#[0-9]+;?|#[xX][0-9a-fA-F]+;?|[^\t\n\x0c <&#;]{1,32};?)").expect("valid") }); /// Decode every character reference in `text`. pub fn unescape(text: &str) -> String { if !text.contains('&') { return text.to_string(); } CHARREF .replace_all(text, |found: ®ex::Captures| replace(&found[1])) .into_owned() } fn replace(reference: &str) -> String { match reference.strip_prefix('#') { Some(number) => numeric(number), None => named(reference), } } fn numeric(number: &str) -> String { let digits = number.trim_end_matches(';'); let value = match digits.strip_prefix(['x', 'X']) { Some(hex) => u32::from_str_radix(hex, 16), None => digits.parse::(), }; // A number too long to be a code point at all is out of range, which the // specification answers the same way as any other out-of-range number. let Ok(value) = value else { return '\u{fffd}'.to_string(); }; if let Ok(at) = INVALID_CHARREFS.binary_search_by_key(&value, |(number, _)| *number) { return INVALID_CHARREFS[at].1.to_string(); } if (0xd800..=0xdfff).contains(&value) || value > 0x10ffff { return '\u{fffd}'.to_string(); } if INVALID_CODEPOINTS.binary_search(&value).is_ok() { return String::new(); } char::from_u32(value).map(String::from).unwrap_or_default() } fn named(name: &str) -> String { if let Some(value) = lookup(name) { return value.to_string(); } // The longest name that is a prefix of what was written wins, and the // rest is left as text: `¬it;` is `¬it;`, not an unknown reference. for end in (2..name.len()).rev() { if !name.is_char_boundary(end) { continue; } if let Some(value) = lookup(&name[..end]) { return format!("{value}{}", &name[end..]); } } format!("&{name}") } fn lookup(name: &str) -> Option<&'static str> { NAMED .binary_search_by_key(&name, |(named, _)| named) .ok() .map(|at| NAMED[at].1) } #[cfg(test)] mod tests { use super::*; #[test] fn the_table_is_sorted_so_it_can_be_searched() { assert!(NAMED.windows(2).all(|pair| pair[0].0 < pair[1].0)); assert!( INVALID_CHARREFS .windows(2) .all(|pair| pair[0].0 < pair[1].0) ); assert!(INVALID_CODEPOINTS.windows(2).all(|pair| pair[0] < pair[1])); } #[test] fn ordinary_references_decode() { assert_eq!(unescape("a & b"), "a & b"); assert_eq!(unescape("<sup>107"), "107"); assert_eq!(unescape("AB"), "AB"); } #[test] fn a_missing_semicolon_is_still_a_reference() { assert_eq!(unescape("&"), "&"); assert_eq!(unescape("A"), "A"); assert_eq!(unescape("Æ"), "Æ"); } #[test] fn the_longest_name_wins_and_the_rest_stays_text() { assert_eq!(unescape("¬it;"), "¬it;"); assert_eq!(unescape("∉"), "∉"); } #[test] fn something_that_is_not_a_reference_is_left_alone() { assert_eq!(unescape("&unknown;"), "&unknown;"); assert_eq!(unescape("a & b"), "a & b"); assert_eq!(unescape("no ampersand here"), "no ampersand here"); } #[test] fn windows_1252_numbers_decode_as_the_specification_says() { assert_eq!(unescape("€"), "€"); assert_eq!(unescape("�"), "\u{fffd}"); } #[test] fn a_number_that_is_not_a_code_point_is_refused() { assert_eq!(unescape("�"), "\u{fffd}"); assert_eq!(unescape("�"), "\u{fffd}"); assert_eq!(unescape("�"), "\u{fffd}"); } #[test] fn a_code_point_the_specification_drops_comes_out_as_nothing() { assert_eq!(unescape("ab"), "ab"); } }