html_entity_fix/lib.rs
1//! # html-entity-fix
2//!
3//! Decode HTML entities (`&`, `<`, `>`, `"`, `'`,
4//! `'`, etc.) inside text that was supposed to be plain.
5//!
6//! LLMs sometimes emit HTML-escaped text into JSON or chat output —
7//! usually because they over-corrected from a prior HTML context.
8//! This crate decodes the common cases without pulling in a full
9//! HTML parser.
10//!
11//! ## Example
12//!
13//! ```
14//! use html_entity_fix::fix;
15//! assert_eq!(fix("AT&T"), "AT&T");
16//! assert_eq!(fix("<tag>"), "<tag>");
17//! assert_eq!(fix("'hello'"), "'hello'");
18//! ```
19
20#![deny(missing_docs)]
21
22/// Decode named and numeric HTML entities in `s`. Unknown entities are
23/// passed through unchanged.
24pub fn fix(s: &str) -> String {
25 let mut out = String::with_capacity(s.len());
26 let mut i = 0;
27 let bytes = s.as_bytes();
28 while i < bytes.len() {
29 if bytes[i] == b'&' {
30 if let Some((decoded, len)) = decode_entity(&s[i..]) {
31 out.push(decoded);
32 i += len;
33 continue;
34 }
35 }
36 // Safe to push a single byte directly only if it's ASCII; we
37 // fall back to taking one char in the general case so multi-byte
38 // UTF-8 sequences stay intact.
39 if bytes[i] < 0x80 {
40 out.push(bytes[i] as char);
41 i += 1;
42 } else {
43 let c = s[i..].chars().next().unwrap();
44 out.push(c);
45 i += c.len_utf8();
46 }
47 }
48 out
49}
50
51/// Returns `Some((decoded_char, bytes_consumed))` on a successful decode
52/// starting at `s` (which must begin with `&`).
53fn decode_entity(s: &str) -> Option<(char, usize)> {
54 let bytes = s.as_bytes();
55 if bytes.first() != Some(&b'&') {
56 return None;
57 }
58 let semi = s[1..].find(';')?;
59 let inside = &s[1..1 + semi];
60 let consumed = semi + 2; // & ... ;
61
62 // Numeric: &#NNN; or &#xHH;
63 if let Some(rest) = inside.strip_prefix('#') {
64 let code = if let Some(hex) = rest.strip_prefix('x').or(rest.strip_prefix('X')) {
65 u32::from_str_radix(hex, 16).ok()?
66 } else {
67 rest.parse::<u32>().ok()?
68 };
69 let c = char::from_u32(code)?;
70 return Some((c, consumed));
71 }
72
73 let c = match inside {
74 "amp" => '&',
75 "lt" => '<',
76 "gt" => '>',
77 "quot" => '"',
78 "apos" => '\'',
79 "nbsp" => '\u{00A0}',
80 "copy" => '©',
81 "reg" => '®',
82 "trade" => '™',
83 "hellip" => '…',
84 "mdash" => '—',
85 "ndash" => '–',
86 "lsquo" => '‘',
87 "rsquo" => '’',
88 "ldquo" => '“',
89 "rdquo" => '”',
90 _ => return None,
91 };
92 Some((c, consumed))
93}