use crate::entities::ENTITIES;
const MAX_NAME: usize = 32;
const MAX_LEGACY: usize = 6;
pub(crate) fn decode_into(out: &mut String, raw: &str, in_attr: bool) {
let bytes = raw.as_bytes();
let mut last = 0;
let mut i = 0;
while let Some(off) = memchr::memchr(b'&', &bytes[i..]) {
let amp = i + off;
out.push_str(&raw[last..amp]);
match reference(raw, amp + 1, in_attr) {
Some((a, b, end)) => {
out.push(a);
if b != '\0' {
out.push(b);
}
last = end;
i = end;
}
None => {
out.push('&');
last = amp + 1;
i = amp + 1;
}
}
}
out.push_str(&raw[last..]);
}
fn reference(raw: &str, start: usize, in_attr: bool) -> Option<(char, char, usize)> {
let bytes = raw.as_bytes();
match bytes.get(start) {
Some(b'#') => numeric(bytes, start + 1),
Some(c) if c.is_ascii_alphanumeric() => named(raw, start, in_attr),
_ => None,
}
}
fn numeric(bytes: &[u8], start: usize) -> Option<(char, char, usize)> {
let (hex, mut i) = match bytes.get(start) {
Some(b'x' | b'X') => (true, start + 1),
_ => (false, start),
};
let digits_start = i;
let mut value: u32 = 0;
while let Some(&c) = bytes.get(i) {
let d = match c {
b'0'..=b'9' => c - b'0',
b'a'..=b'f' if hex => c - b'a' + 10,
b'A'..=b'F' if hex => c - b'A' + 10,
_ => break,
};
value = value
.saturating_mul(if hex { 16 } else { 10 })
.saturating_add(d as u32);
i += 1;
}
if i == digits_start {
return None;
}
if bytes.get(i) == Some(&b';') {
i += 1;
}
Some((numeric_char(value), '\0', i))
}
fn numeric_char(value: u32) -> char {
const C1: [u32; 32] = [
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160,
0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013,
0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
];
let v = match value {
0x80..=0x9F => C1[(value - 0x80) as usize],
v => v,
};
if v == 0 {
return '\u{FFFD}';
}
char::from_u32(v).unwrap_or('\u{FFFD}')
}
fn named(raw: &str, start: usize, in_attr: bool) -> Option<(char, char, usize)> {
let bytes = raw.as_bytes();
let mut end = start;
while end < bytes.len() && end - start < MAX_NAME && bytes[end].is_ascii_alphanumeric() {
end += 1;
}
if bytes.get(end) == Some(&b';')
&& let Some((a, b)) = lookup(&raw[start..=end])
{
return Some((a, b, end + 1));
}
for len in (1..=(end - start).min(MAX_LEGACY)).rev() {
let Some((a, b)) = lookup(&raw[start..start + len]) else {
continue;
};
let next = bytes.get(start + len).copied();
if in_attr && next.is_some_and(|c| c.is_ascii_alphanumeric() || c == b'=') {
return None;
}
return Some((a, b, start + len));
}
None
}
fn lookup(name: &str) -> Option<(char, char)> {
ENTITIES
.binary_search_by(|(n, _, _)| n.as_bytes().cmp(name.as_bytes()))
.ok()
.map(|i| (ENTITIES[i].1, ENTITIES[i].2))
}
#[cfg(test)]
mod tests {
fn text(s: &str) -> String {
let mut out = String::new();
super::decode_into(&mut out, s, false);
out
}
fn attr(s: &str) -> String {
let mut out = String::new();
super::decode_into(&mut out, s, true);
out
}
#[test]
fn named_and_numeric() {
assert_eq!(text("a & b <c>"), "a & b <c>");
assert_eq!(text("©©©©"), "©©©©");
assert_eq!(text(" x"), "\u{a0}x");
assert_eq!(text("∾̳"), "\u{223e}\u{333}");
assert_eq!(
text("���€"),
"\u{FFFD}\u{FFFD}\u{FFFD}€"
);
assert_eq!(text("�"), "\u{FFFD}");
}
#[test]
fn not_references() {
assert_eq!(
text("AT&T & co &#; &#x; &unknown; &"),
"AT&T & co &#; &#x; &unknown; &"
);
assert_eq!(text("¬it; &"), "¬it; &");
}
#[test]
fn attribute_rules() {
assert_eq!(attr("?a=1©=2&lang=en"), "?a=1©=2&lang=en");
assert_eq!(attr("© 2024 &x"), "© 2024 &x");
assert_eq!(attr("&x"), "&x");
}
}