const CP1252_HIGH: [char; 32] = [
'\u{20AC}', '\u{FFFD}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}', '\u{2021}',
'\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{FFFD}', '\u{017D}', '\u{FFFD}',
'\u{FFFD}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}', '\u{2022}', '\u{2013}', '\u{2014}',
'\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}', '\u{0153}', '\u{FFFD}', '\u{017E}', '\u{0178}',
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Charset {
Utf8,
Cp1252,
Unsupported(String),
}
pub fn classify(label: &str) -> Charset {
let normalized = label
.trim()
.trim_matches('"')
.to_ascii_lowercase()
.replace('_', "-");
match normalized.as_str() {
"" | "utf-8" | "utf8" | "us-ascii" | "ascii" => Charset::Utf8,
"windows-1252" | "cp1252" | "iso-8859-1" | "latin1" | "latin-1" | "iso8859-1"
| "iso-latin-1" | "ansi-x3.4-1968" => Charset::Cp1252,
_ => Charset::Unsupported(label.trim().trim_matches('"').to_string()),
}
}
pub fn decode(bytes: &[u8], label: Option<&str>) -> (String, Option<String>) {
match label.map(classify).unwrap_or(Charset::Utf8) {
Charset::Utf8 => (String::from_utf8_lossy(bytes).into_owned(), None),
Charset::Cp1252 => (decode_cp1252(bytes), None),
Charset::Unsupported(name) => (String::from_utf8_lossy(bytes).into_owned(), Some(name)),
}
}
pub fn decode_cp1252(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len());
for &b in bytes {
match b {
0x00..=0x7F => out.push(b as char),
0x80..=0x9F => out.push(CP1252_HIGH[(b - 0x80) as usize]),
_ => out.push(b as char), }
}
out
}
pub fn from_content_type(header: &str) -> Option<String> {
header.split(';').find_map(|part| {
let part = part.trim();
let rest = part.strip_prefix("charset=").or_else(|| {
part.to_ascii_lowercase()
.starts_with("charset=")
.then(|| &part["charset=".len()..])
})?;
let value = rest.trim().trim_matches('"');
(!value.is_empty()).then(|| value.to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn utf8_labels_need_no_special_handling() {
assert_eq!(classify("utf-8"), Charset::Utf8);
assert_eq!(classify("UTF-8"), Charset::Utf8);
assert_eq!(classify(" \"utf8\" "), Charset::Utf8);
assert_eq!(classify("us-ascii"), Charset::Utf8);
}
#[test]
fn latin_labels_all_decode_as_cp1252() {
for label in [
"ISO-8859-1",
"iso_8859-1",
"latin1",
"windows-1252",
"CP1252",
] {
assert_eq!(classify(label), Charset::Cp1252, "{label}");
}
}
#[test]
fn a_latin1_body_decodes_correctly() {
let bytes = b"Caf\xe9 \x97 na\xefve";
let (text, reported) = decode(bytes, Some("ISO-8859-1"));
assert_eq!(text, "Café — naïve");
assert_eq!(reported, None, "an exact decode reports no problem");
}
#[test]
fn cp1252_smart_quotes_survive() {
let bytes = b"\x93quoted\x94 \x85";
assert_eq!(decode_cp1252(bytes), "“quoted” …");
}
#[test]
fn an_unsupported_charset_is_reported_rather_than_hidden() {
let (_, reported) = decode(b"\x82\xa0", Some("Shift_JIS"));
assert_eq!(reported.as_deref(), Some("Shift_JIS"));
}
#[test]
fn utf8_bodies_round_trip() {
let (text, reported) = decode("Café — naïve".as_bytes(), Some("utf-8"));
assert_eq!(text, "Café — naïve");
assert!(reported.is_none());
}
#[test]
fn charset_is_read_out_of_a_content_type() {
assert_eq!(
from_content_type("text/html; charset=ISO-8859-1").as_deref(),
Some("ISO-8859-1")
);
assert_eq!(
from_content_type("text/html;charset=\"utf-8\"").as_deref(),
Some("utf-8")
);
assert_eq!(from_content_type("text/html").as_deref(), None);
}
}