const NAMED: &[(&str, &str)] = &[
("amp", "&"),
("lt", "<"),
("gt", ">"),
("quot", "\""),
("apos", "'"),
("nbsp", "\u{a0}"),
("ensp", "\u{2002}"),
("emsp", "\u{2003}"),
("thinsp", "\u{2009}"),
("shy", "\u{ad}"),
("ndash", "–"),
("mdash", "—"),
("minus", "−"),
("lsquo", "\u{2018}"),
("rsquo", "\u{2019}"),
("sbquo", "\u{201a}"),
("ldquo", "\u{201c}"),
("rdquo", "\u{201d}"),
("bdquo", "\u{201e}"),
("laquo", "«"),
("raquo", "»"),
("lsaquo", "\u{2039}"),
("rsaquo", "\u{203a}"),
("copy", "©"),
("reg", "®"),
("trade", "™"),
("cent", "¢"),
("pound", "£"),
("yen", "¥"),
("euro", "€"),
("curren", "¤"),
("sect", "§"),
("para", "¶"),
("hellip", "…"),
("bull", "•"),
("middot", "·"),
("dagger", "†"),
("Dagger", "‡"),
("prime", "′"),
("Prime", "″"),
("deg", "°"),
("plusmn", "±"),
("times", "×"),
("divide", "÷"),
("frac12", "½"),
("frac14", "¼"),
("frac34", "¾"),
("sup1", "¹"),
("sup2", "²"),
("sup3", "³"),
("micro", "µ"),
("permil", "‰"),
("infin", "∞"),
("ne", "≠"),
("le", "≤"),
("ge", "≥"),
("larr", "←"),
("rarr", "→"),
("harr", "↔"),
("iexcl", "¡"),
("iquest", "¿"),
("brvbar", "¦"),
("uml", "¨"),
("macr", "¯"),
("acute", "´"),
("cedil", "¸"),
("ordf", "ª"),
("ordm", "º"),
("not", "¬"),
];
pub fn resolve_entity(name: &str) -> String {
if let Some(rest) = name.strip_prefix('#') {
let cp = match rest.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok(),
None => rest.parse::<u32>().ok(),
};
if let Some(c) = cp.and_then(char::from_u32) {
return c.to_string();
}
return format!("&{name};");
}
match NAMED.iter().find(|(n, _)| *n == name) {
Some((_, text)) => (*text).to_string(),
None => format!("&{name};"),
}
}
pub fn decode_attr_value(raw: &[u8]) -> String {
let s = String::from_utf8_lossy(raw);
if !s.contains('&') {
return s.into_owned();
}
let mut out = String::with_capacity(s.len());
let mut rest: &str = s.as_ref();
while let Some(amp) = rest.find('&') {
out.push_str(&rest[..amp]);
let after = &rest[amp + 1..];
let name_end = after
.find(|c: char| !c.is_ascii_alphanumeric() && c != '#')
.unwrap_or(after.len());
if name_end > 0 && after[name_end..].starts_with(';') {
out.push_str(&resolve_entity(&after[..name_end]));
rest = &after[name_end + 1..];
} else {
out.push('&');
rest = after;
}
}
out.push_str(rest);
out
}
pub fn decode_attr(attr: &quick_xml::events::attributes::Attribute<'_>) -> String {
decode_attr_value(attr.value.as_ref())
}
pub fn resolve_entity_bytes(raw: &[u8]) -> String {
match std::str::from_utf8(raw) {
Ok(name) => resolve_entity(name),
Err(_) => String::new(),
}
}
macro_rules! read_event_folding_entities {
($reader:expr, $buf:expr, $spill:expr, $is_entity:expr) => {
match $reader.read_event_into($buf) {
Ok(quick_xml::events::Event::GeneralRef(r)) => {
*$spill = $crate::entities::resolve_entity_bytes(r.as_ref());
*$is_entity = true;
Ok(quick_xml::events::Event::Text(
quick_xml::events::BytesText::from_escaped($spill.as_str()),
))
}
other => {
*$is_entity = false;
other
}
}
};
($reader:expr, $buf:expr, $spill:expr) => {{
let mut ignored = false;
read_event_folding_entities!($reader, $buf, $spill, &mut ignored)
}};
}
pub(crate) use read_event_folding_entities;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolves_the_five_xml_predefined_entities() {
assert_eq!(resolve_entity("amp"), "&");
assert_eq!(resolve_entity("lt"), "<");
assert_eq!(resolve_entity("gt"), ">");
assert_eq!(resolve_entity("quot"), "\"");
assert_eq!(resolve_entity("apos"), "'");
}
#[test]
fn resolves_decimal_and_hex_character_references() {
assert_eq!(resolve_entity("#8212"), "—");
assert_eq!(resolve_entity("#x2014"), "—");
assert_eq!(resolve_entity("#X2014"), "—");
assert_eq!(resolve_entity("#169"), "©");
}
#[test]
fn resolves_html_named_entities_that_appear_in_real_documents() {
assert_eq!(resolve_entity("copy"), "©");
assert_eq!(resolve_entity("nbsp"), "\u{a0}");
assert_eq!(resolve_entity("rsquo"), "\u{2019}");
assert_eq!(resolve_entity("hellip"), "…");
}
#[test]
fn unknown_entities_stay_visible_rather_than_vanishing() {
assert_eq!(resolve_entity("notAnEntity"), "¬AnEntity;");
assert_eq!(resolve_entity("#1114112"), "�");
assert_eq!(resolve_entity("#xZZ"), "&#xZZ;");
}
#[test]
fn surrogate_code_points_are_not_forged_into_chars() {
assert_eq!(resolve_entity("#xD800"), "�");
}
fn decode(s: &str) -> String {
decode_attr_value(s.as_bytes())
}
#[test]
fn attribute_values_without_an_ampersand_are_returned_unchanged() {
assert_eq!(decode("word/media/image1.png"), "word/media/image1.png");
assert_eq!(decode(""), "");
}
#[test]
fn relationship_targets_have_their_ampersands_decoded() {
assert_eq!(
decode("http://www.scopus.com/record.url?eid=2-s2.0-00249&partnerID=K84&rel=3.0.0"),
"http://www.scopus.com/record.url?eid=2-s2.0-00249&partnerID=K84&rel=3.0.0"
);
}
#[test]
fn attribute_values_decode_the_same_table_element_text_does() {
assert_eq!(decode("<b>"), "<b>");
assert_eq!(decode(""quoted""), "\"quoted\"");
assert_eq!(decode("R&D — ©"), "R&D — ©");
}
#[test]
fn a_stray_ampersand_is_kept_rather_than_swallowed() {
assert_eq!(decode("a & b"), "a & b");
assert_eq!(decode("q?a=1&b=2"), "q?a=1&b=2");
assert_eq!(decode("trailing&"), "trailing&");
}
#[test]
fn unknown_references_in_attributes_stay_visible() {
assert_eq!(decode("¬AnEntity;"), "¬AnEntity;");
}
}