const ACCENTS: &[(&str, &str, char)] = &[
("`A", "Agrave", 'À'),
("`a", "agrave", 'à'),
("`E", "Egrave", 'È'),
("`e", "egrave", 'è'),
("`I", "Igrave", 'Ì'),
("`i", "igrave", 'ì'),
("`O", "Ograve", 'Ò'),
("`o", "ograve", 'ò'),
("`U", "Ugrave", 'Ù'),
("`u", "ugrave", 'ù'),
("'A", "Aacute", 'Á'),
("'a", "aacute", 'á'),
("'E", "Eacute", 'É'),
("'e", "eacute", 'é'),
("'I", "Iacute", 'Í'),
("'i", "iacute", 'í'),
("'O", "Oacute", 'Ó'),
("'o", "oacute", 'ó'),
("'U", "Uacute", 'Ú'),
("'u", "uacute", 'ú'),
("'Y", "Yacute", 'Ý'),
("'y", "yacute", 'ý'),
("^A", "Acirc", 'Â'),
("^a", "acirc", 'â'),
("^E", "Ecirc", 'Ê'),
("^e", "ecirc", 'ê'),
("^I", "Icirc", 'Î'),
("^i", "icirc", 'î'),
("^O", "Ocirc", 'Ô'),
("^o", "ocirc", 'ô'),
("^U", "Ucirc", 'Û'),
("^u", "ucirc", 'û'),
("^Y", "Ycirc", 'Ŷ'),
("^y", "ycirc", 'ŷ'),
("~A", "Atilde", 'Ã'),
("~a", "atilde", 'ã'),
("~N", "Ntilde", 'Ñ'),
("~n", "ntilde", 'ñ'),
("~O", "Otilde", 'Õ'),
("~o", "otilde", 'õ'),
("\"A", "Auml", 'Ä'),
("\"a", "auml", 'ä'),
("\"E", "Euml", 'Ë'),
("\"e", "euml", 'ë'),
("\"I", "Iuml", 'Ï'),
("\"i", "iuml", 'ï'),
("\"O", "Ouml", 'Ö'),
("\"o", "ouml", 'ö'),
("\"U", "Uuml", 'Ü'),
("\"u", "uuml", 'ü'),
("\"Y", "Yuml", 'Ÿ'),
("\"y", "yuml", 'ÿ'),
("cC", "Ccedil", 'Ç'),
("cc", "ccedil", 'ç'),
("AA", "Aring", 'Å'),
("aa", "aring", 'å'),
("/O", "Oslash", 'Ø'),
("/o", "oslash", 'ø'),
("uA", "Abreve", 'Ă'),
("ua", "abreve", 'ă'),
("uE", "", 'Ĕ'),
("ue", "", 'ĕ'),
("vS", "Scaron", 'Š'),
("vs", "scaron", 'š'),
("vZ", "Zcaron", 'Ž'),
("vz", "zcaron", 'ž'),
("HO", "", 'Ő'),
("Ho", "", 'ő'),
("HU", "", 'Ű'),
("Hu", "", 'ű'),
("AE", "AElig", 'Æ'),
("ae", "aelig", 'æ'),
("OE", "OElig", 'Œ'),
("oe", "oelig", 'œ'),
("ss", "szlig", 'ß'),
("DH", "ETH", 'Ð'),
("dh", "eth", 'ð'),
("TH", "THORN", 'Þ'),
("th", "thorn", 'þ'),
(",C", "", 'Ç'),
(",c", "", 'ç'),
("oA", "", 'Å'),
("oa", "", 'å'),
];
const OTHER_ENTITIES: &[(&str, char)] = &[
("quot", '"'),
("copy", '©'),
("amp", '&'),
("lt", '<'),
("gt", '>'),
("apos", '\''),
("nbsp", '\u{a0}'),
];
pub(crate) fn decode_escape_at(text: &str) -> Option<(char, usize)> {
let rest = text.strip_prefix('\\')?;
let mut chars = rest.chars();
let first = chars.next()?;
match first {
'\\' | '%' | '&' => return Some((first, 2)),
'u' => {
if let Some(ch) = hex_char(rest.get(1..5)) {
return Some((ch, 6));
}
}
'U' => {
if let Some(ch) = hex_char(rest.get(1..9)) {
return Some((ch, 10));
}
}
_ => {}
}
if let Some(pair) = rest.get(..2)
&& let Some((_, _, ch)) = ACCENTS.iter().find(|(mnemonic, _, _)| *mnemonic == pair)
{
return Some((*ch, 3));
}
(first == '"').then_some(('"', 2))
}
pub(crate) fn decode_entity_at(text: &str) -> Option<(char, usize)> {
let rest = text.strip_prefix('&')?;
let end = rest.find(';')?;
let name = rest.get(..end)?;
let consumed = end + 2;
if let Some(number) = name.strip_prefix('#') {
let value = match number.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => number.parse::<u32>().ok()?,
};
return char::from_u32(value).map(|ch| (ch, consumed));
}
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
ACCENTS
.iter()
.find(|(_, entity, _)| !entity.is_empty() && *entity == name)
.map(|(_, _, ch)| *ch)
.or_else(|| {
OTHER_ENTITIES
.iter()
.find(|(entity, _)| *entity == name)
.map(|(_, ch)| *ch)
})
.map(|ch| (ch, consumed))
}
fn hex_char(digits: Option<&str>) -> Option<char> {
let digits = digits?;
if !digits.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
char::from_u32(u32::from_str_radix(digits, 16).ok()?)
}
pub(crate) fn decode_text_string(text: &str) -> String {
if !text.contains(['\\', '&']) {
return text.to_owned();
}
let mut out = String::with_capacity(text.len());
let mut index = 0;
while let Some(rest) = text.get(index..) {
let Some(ch) = rest.chars().next() else {
break;
};
let decoded = match ch {
'\\' => decode_escape_at(rest),
'&' => decode_entity_at(rest),
_ => None,
};
match decoded {
Some((decoded, consumed)) => {
out.push(decoded);
index += consumed;
}
None => {
out.push(ch);
index += ch.len_utf8();
}
}
}
out
}
pub(crate) fn encode_text_string(text: &str, quoted: bool) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\\' => out.push_str("\\\\"),
'%' => out.push_str("\\%"),
'&' if chars
.peek()
.is_some_and(|next| next.is_ascii_alphabetic() || *next == '#') =>
{
out.push_str("\\&")
}
'"' if quoted => out.push_str("""),
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
#[path = "text_tests.rs"]
mod tests;