Skip to main content

astral_html/
entities.rs

1//! HTML character references and input newline normalization.
2
3use std::borrow::Cow;
4
5use crate::entities_data::{LABELS, NODES, NamedNode, VALUE_DATA, VALUES};
6
7/// Decode HTML character references and normalize input CRLF and CR to LF.
8///
9/// In attribute mode, NUL becomes U+FFFD and semicolonless named references are left
10/// intact before ASCII letters, digits, or `=`. Unchanged input is borrowed.
11/// References are decoded once: `<` becomes `<`.
12#[inline]
13pub fn decode(input: &str, attribute: bool) -> Cow<'_, str> {
14    let bytes = input.as_bytes();
15    let first = if bytes.len() < 16 {
16        bytes
17            .iter()
18            .position(|&byte| matches!(byte, b'&' | b'\r') || (attribute && byte == 0))
19    } else if attribute {
20        memchr::memchr3(b'&', b'\r', 0, bytes)
21    } else {
22        memchr::memchr2(b'&', b'\r', bytes)
23    };
24    let Some(first) = first else {
25        return Cow::Borrowed(input);
26    };
27    decode_from(input, attribute, first)
28}
29
30/// Start at an ASCII marker from the initial scan; preceding bytes need no transformation.
31#[inline(never)]
32pub(crate) fn decode_from(input: &str, attribute: bool, mut cursor: usize) -> Cow<'_, str> {
33    let bytes = input.as_bytes();
34    let mut output = None;
35    let mut unchanged = 0;
36    while cursor < bytes.len() {
37        let (next, replacement) = match bytes[cursor] {
38            b'&' => {
39                let Some((consumed, replacement)) = reference(&input[cursor + 1..], attribute)
40                else {
41                    cursor += 1;
42                    continue;
43                };
44                (cursor + consumed + 1, replacement)
45            }
46            b'\r' => (
47                cursor + 1 + usize::from(bytes.get(cursor + 1) == Some(&b'\n')),
48                Replacement::Character('\n'),
49            ),
50            0 if attribute => (cursor + 1, Replacement::Character('\u{fffd}')),
51            _ => {
52                cursor += if attribute {
53                    memchr::memchr3(b'&', b'\r', 0, &bytes[cursor..])
54                } else {
55                    memchr::memchr2(b'&', b'\r', &bytes[cursor..])
56                }
57                .unwrap_or(bytes.len() - cursor);
58                continue;
59            }
60        };
61        let output = output.get_or_insert_with(|| String::with_capacity(input.len()));
62        output.push_str(&input[unchanged..cursor]);
63        match replacement {
64            Replacement::Character(character) => output.push(character),
65            Replacement::Named(text) => output.push_str(text),
66        }
67        cursor = next;
68        unchanged = next;
69    }
70    if let Some(mut output) = output {
71        output.push_str(&input[unchanged..]);
72        // Numeric references can consume arbitrarily many digits for one character.
73        // Keep small buffers to avoid reallocating ordinary short attribute values.
74        if output.capacity() > 64 && output.capacity() > output.len().saturating_mul(2) {
75            output.shrink_to_fit();
76        }
77        Cow::Owned(output)
78    } else {
79        Cow::Borrowed(input)
80    }
81}
82
83/// Normalize HTML input newlines, leaving character references and NUL intact.
84pub fn normalize(input: &str) -> Cow<'_, str> {
85    let Some(first) = memchr::memchr(b'\r', input.as_bytes()) else {
86        return Cow::Borrowed(input);
87    };
88    let mut output = String::with_capacity(input.len());
89    output.push_str(&input[..first]);
90    let mut rest = &input[first..];
91    while let Some(offset) = memchr::memchr(b'\r', rest.as_bytes()) {
92        output.push_str(&rest[..offset]);
93        output.push('\n');
94        rest = &rest[offset + 1..];
95        if let Some(suffix) = rest.strip_prefix('\n') {
96            rest = suffix;
97        }
98    }
99    output.push_str(rest);
100    Cow::Owned(output)
101}
102
103enum Replacement {
104    Character(char),
105    Named(&'static str),
106}
107
108/// Decode a reference after `&`, returning the number of bytes consumed after it.
109fn reference(input: &str, attribute: bool) -> Option<(usize, Replacement)> {
110    let bytes = input.as_bytes();
111    if bytes.first() == Some(&b'#') {
112        let hex = matches!(bytes.get(1), Some(b'x' | b'X'));
113        let start = if hex { 2 } else { 1 };
114        let radix = if hex { 16 } else { 10 };
115        let mut end = start;
116        let mut number = 0u32;
117        while let Some(digit) = bytes
118            .get(end)
119            .and_then(|byte| char::from(*byte).to_digit(radix))
120        {
121            number = number.saturating_mul(radix).saturating_add(digit);
122            end += 1;
123        }
124        if end == start {
125            return None;
126        }
127        if bytes.get(end) == Some(&b';') {
128            end += 1;
129        }
130        return Some((end, Replacement::Character(numeric(number))));
131    }
132
133    // Common semicolon-terminated references need no contextual lookahead.
134    let common = match bytes {
135        [b'g', b't', b';', ..] => Some((3, ">")),
136        [b'l', b't', b';', ..] => Some((3, "<")),
137        [b'a', b'm', b'p', b';', ..] => Some((4, "&")),
138        [b'q', b'u', b'o', b't', b';', ..] => Some((5, "\"")),
139        [b'a', b'p', b'o', b's', b';', ..] => Some((5, "'")),
140        _ => None,
141    };
142    if let Some((length, value)) = common {
143        return Some((length, Replacement::Named(value)));
144    }
145
146    let initial = match bytes.first()? {
147        b'A'..=b'Z' => usize::from(bytes[0] - b'A'),
148        b'a'..=b'z' => usize::from(bytes[0] - b'a') + 26,
149        _ => return None,
150    };
151    let mut node = &NODES[initial];
152    let mut consumed = 0;
153    let mut matched = None;
154    loop {
155        let &NamedNode(label, children, value, label_len, child_count) = node;
156        let label = usize::from(label);
157        if !bytes[consumed..].starts_with(&LABELS[label..label + usize::from(label_len)]) {
158            break;
159        }
160        consumed += usize::from(label_len);
161        if value != 0 {
162            if bytes.get(consumed) == Some(&b';') {
163                matched = Some((consumed + 1, value));
164                break;
165            }
166            // The high bit marks a legacy spelling that can omit its semicolon.
167            if value & 0x8000 != 0
168                && (!attribute
169                    || !bytes
170                        .get(consumed)
171                        .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'='))
172            {
173                matched = Some((consumed, value));
174            }
175        }
176        let Some(&next) = bytes.get(consumed) else {
177            break;
178        };
179        let children = usize::from(children);
180        let children = &NODES[children..children + usize::from(child_count)];
181        let Ok(index) = children.binary_search_by_key(&next, |node| LABELS[usize::from(node.0)])
182        else {
183            break;
184        };
185        node = &children[index];
186    }
187    matched.map(|(length, value)| {
188        let (start, len) = VALUES[usize::from((value & 0x7fff) - 1)];
189        let start = usize::from(start);
190        (
191            length,
192            Replacement::Named(&VALUE_DATA[start..start + usize::from(len)]),
193        )
194    })
195}
196
197/// Apply the numeric reference replacement rules, including Windows-1252.
198fn numeric(number: u32) -> char {
199    const C1: [u32; 32] = [
200        0x20ac, 0x81, 0x201a, 0x192, 0x201e, 0x2026, 0x2020, 0x2021, 0x2c6, 0x2030, 0x160, 0x2039,
201        0x152, 0x8d, 0x17d, 0x8f, 0x90, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014,
202        0x2dc, 0x2122, 0x161, 0x203a, 0x153, 0x9d, 0x17e, 0x178,
203    ];
204    let number = match number {
205        0 => 0xfffd,
206        0x80..=0x9f => C1[(number - 0x80) as usize],
207        number => number,
208    };
209    char::from_u32(number).unwrap_or('\u{fffd}')
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::entities_data::NAMED;
216
217    #[test]
218    fn named_references_follow_attribute_context() {
219        assert_eq!(
220            decode("&notit; &amp=1 &AMP; &NotEqualTilde;", false),
221            "¬it; &=1 & ≂\u{338}"
222        );
223        assert_eq!(
224            decode("&notit; &amp=1 &AMP; &NotEqualTilde;", true),
225            "&notit; &amp=1 & ≂\u{338}"
226        );
227        assert_eq!(decode("&amp;lt;", true), "&lt;");
228    }
229
230    #[test]
231    fn unterminated_named_references_choose_the_longest_legacy_prefix() {
232        for &(name, _) in NAMED {
233            let name = name.trim_end_matches(';');
234            let source = format!("&{name}");
235            let legacy = NAMED
236                .iter()
237                .filter(|(legacy, _)| !legacy.ends_with(';') && name.starts_with(legacy))
238                .max_by_key(|(legacy, _)| legacy.len());
239            let expected = legacy.map_or_else(
240                || source.clone(),
241                |(legacy, value)| format!("{value}{}", &name[legacy.len()..]),
242            );
243            assert_eq!(decode(&source, false), expected, "{source}");
244            let expected = legacy
245                .filter(|(legacy, _)| legacy.len() == name.len())
246                .map_or(source.as_str(), |(_, value)| *value);
247            assert_eq!(decode(&source, true), expected, "{source}");
248        }
249    }
250
251    #[test]
252    fn unchanged_ampersands_borrow_the_input() {
253        for source in [
254            "a & b",
255            "?a=1&unknown=2",
256            "&unknown;",
257            "&#x;",
258            "é &unknown; 🦀",
259        ] {
260            for attribute in [false, true] {
261                assert!(
262                    matches!(decode(source, attribute), Cow::Borrowed(value) if value == source)
263                );
264            }
265        }
266        assert!(matches!(decode("&notit;", true), Cow::Borrowed("&notit;")));
267        assert_eq!(
268            decode("&unknown; then &amp; and &unknown;", false),
269            "&unknown; then & and &unknown;"
270        );
271    }
272
273    #[test]
274    fn numeric_references_replace_invalid_scalars() {
275        assert_eq!(
276            decode("&#0; &#xD800; &#1114112; &#128; &#x1F980;", false),
277            "� � � € 🦀"
278        );
279        assert_eq!(decode("&#999999999999999999999999;", true), "�");
280        assert_eq!(decode("&#x; &#; &#65=", true), "&#x; &#; A=");
281    }
282
283    #[test]
284    fn compressed_references_do_not_retain_input_sized_buffers() {
285        for (source, expected) in [
286            (format!("&#{}65;", "0".repeat(1024 * 1024)), "A".to_owned()),
287            (format!("&#x{}41;", "0".repeat(1024 * 1024)), "A".to_owned()),
288            ("&#65;".repeat(8192), "A".repeat(8192)),
289            (
290                "&CounterClockwiseContourIntegral;".repeat(8192),
291                "∳".repeat(8192),
292            ),
293        ] {
294            for attribute in [false, true] {
295                let Cow::Owned(decoded) = decode(&source, attribute) else {
296                    panic!("decoded references must be owned");
297                };
298                assert_eq!(decoded, expected);
299                assert!(decoded.capacity() <= 2 * decoded.len());
300            }
301        }
302    }
303
304    #[test]
305    fn newlines_and_null_are_contextual() {
306        assert_eq!(decode("a\r\nb\rc\0", true), "a\nb\nc�");
307        assert_eq!(decode("a\r\nb\rc\0", false), "a\nb\nc\0");
308        assert_eq!(normalize("\r\n\r&copy;\0"), "\n\n&copy;\0");
309        assert!(matches!(decode("plain 🦀 text", true), Cow::Borrowed(_)));
310    }
311
312    #[test]
313    fn every_named_reference_decodes() {
314        assert_eq!(NAMED.len(), 2231);
315        for &(name, expected) in NAMED {
316            for attribute in [false, true] {
317                assert_eq!(decode(&format!("&{name}"), attribute), expected, "{name}");
318            }
319            if !name.ends_with(';') {
320                let terminated = format!("{name};");
321                assert!(
322                    NAMED.contains(&(terminated.as_str(), expected)),
323                    "missing terminated spelling for {name}"
324                );
325            }
326        }
327    }
328}