#![forbid(unsafe_code)]
use std::borrow::Cow;
use crate::encoding::{transcode_to_utf8_as, Encoding};
use crate::error::Result;
use super::options::HtmlParseOptions;
pub fn sniff_html_encoding(bytes: &[u8], opts: &HtmlParseOptions) -> Encoding {
if let Some(label) = opts.encoding_override.as_deref() {
return label_to_encoding(label);
}
if let Some(enc) = sniff_bom(bytes) {
return enc;
}
let window = opts.encoding_sniff_window.max(64).min(bytes.len());
if let Some(label) = prescan_meta_charset(&bytes[..window]) {
return label_to_encoding(&label);
}
Encoding::Windows1252
}
pub fn decode_html_input<'a>(
bytes: &'a [u8],
opts: &HtmlParseOptions,
) -> Result<(Cow<'a, [u8]>, Encoding)> {
let enc = sniff_html_encoding(bytes, opts);
let decoded = transcode_to_utf8_as(bytes, enc.clone())?;
Ok((decoded, enc))
}
fn sniff_bom(bytes: &[u8]) -> Option<Encoding> {
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
return Some(Encoding::Utf8);
}
if bytes.starts_with(&[0xFE, 0xFF]) {
return Some(Encoding::Utf16Be);
}
if bytes.starts_with(&[0xFF, 0xFE]) {
return Some(Encoding::Utf16Le);
}
None
}
pub fn prescan_meta_charset(bytes: &[u8]) -> Option<String> {
let mut pos = 0;
while pos < bytes.len() {
let b = bytes[pos];
if starts_with_ascii(&bytes[pos..], b"<!--") {
pos += 4;
while pos + 2 < bytes.len()
&& !(bytes[pos] == b'-' && bytes[pos + 1] == b'-' && bytes[pos + 2] == b'>')
{
pos += 1;
}
pos = (pos + 3).min(bytes.len());
continue;
}
if starts_with_ascii_ignore_case(&bytes[pos..], b"<meta")
&& bytes
.get(pos + 5)
.is_some_and(|&c| matches!(c, b' ' | b'\t' | b'\n' | b'\r' | 0x0C | b'/' | b'>'))
{
pos += 5;
if let Some(label) = parse_meta_attrs(bytes, &mut pos) {
return Some(label);
}
continue;
}
if b == b'<' {
if let Some(end) = find_byte(&bytes[pos..], b'>') {
pos += end + 1;
continue;
}
return None;
}
pos += 1;
}
None
}
fn parse_meta_attrs(bytes: &[u8], pos: &mut usize) -> Option<String> {
let mut http_equiv: Option<Vec<u8>> = None;
let mut content: Option<Vec<u8>> = None;
let mut charset: Option<String> = None;
loop {
while *pos < bytes.len()
&& matches!(bytes[*pos], b' ' | b'\t' | b'\n' | b'\r' | 0x0C | b'/')
{
*pos += 1;
}
if *pos >= bytes.len() {
break;
}
if bytes[*pos] == b'>' {
*pos += 1;
break;
}
let mut name = Vec::new();
while *pos < bytes.len() {
let c = bytes[*pos];
if matches!(c, b'=' | b' ' | b'\t' | b'\n' | b'\r' | 0x0C | b'/' | b'>') {
break;
}
name.push(c.to_ascii_lowercase());
*pos += 1;
}
if name.is_empty() {
if *pos < bytes.len() && bytes[*pos] == b'>' {
*pos += 1;
break;
}
*pos += 1;
continue;
}
while *pos < bytes.len() && matches!(bytes[*pos], b' ' | b'\t' | b'\n' | b'\r' | 0x0C) {
*pos += 1;
}
let mut value: Vec<u8> = Vec::new();
if *pos < bytes.len() && bytes[*pos] == b'=' {
*pos += 1;
while *pos < bytes.len()
&& matches!(bytes[*pos], b' ' | b'\t' | b'\n' | b'\r' | 0x0C)
{
*pos += 1;
}
if *pos < bytes.len() && (bytes[*pos] == b'"' || bytes[*pos] == b'\'') {
let quote = bytes[*pos];
*pos += 1;
while *pos < bytes.len() && bytes[*pos] != quote {
value.push(bytes[*pos]);
*pos += 1;
}
if *pos < bytes.len() {
*pos += 1; }
} else {
while *pos < bytes.len() {
let c = bytes[*pos];
if matches!(c, b' ' | b'\t' | b'\n' | b'\r' | 0x0C | b'>') {
break;
}
value.push(c);
*pos += 1;
}
}
}
match name.as_slice() {
b"charset" => {
if charset.is_none() {
charset = Some(String::from_utf8_lossy(&value).into_owned());
}
}
b"http-equiv" => {
if http_equiv.is_none() {
http_equiv = Some(value);
}
}
b"content" => {
if content.is_none() {
content = Some(value);
}
}
_ => {}
}
}
if let Some(c) = charset {
return Some(c);
}
if let (Some(equiv), Some(content_val)) = (http_equiv, content) {
if ascii_equal_ignore_case(&equiv, b"content-type") {
return extract_charset_from_content(&content_val);
}
}
None
}
fn extract_charset_from_content(content: &[u8]) -> Option<String> {
let lower: Vec<u8> = content.iter().map(|b| b.to_ascii_lowercase()).collect();
let needle = b"charset=";
let pos = lower.windows(needle.len()).position(|w| w == needle)?;
let mut start = pos + needle.len();
if start < content.len() && (content[start] == b'"' || content[start] == b'\'') {
let quote = content[start];
start += 1;
let end = content[start..].iter().position(|&c| c == quote)?;
Some(String::from_utf8_lossy(&content[start..start + end]).into_owned())
} else {
let end = content[start..]
.iter()
.position(|&c| matches!(c, b';' | b' ' | b'\t' | b'\n' | b'\r' | 0x0C))
.unwrap_or(content.len() - start);
Some(String::from_utf8_lossy(&content[start..start + end]).into_owned())
}
}
pub fn label_to_encoding(label: &str) -> Encoding {
let trimmed = label.trim().to_ascii_lowercase();
match trimmed.as_str() {
"utf-8" | "utf8" | "unicode-1-1-utf-8" | "unicode11utf8" | "unicode20utf8"
| "x-unicode20utf8" => Encoding::Utf8,
"us-ascii" | "ascii" | "ansi_x3.4-1968" | "iso646-us" | "iso-ir-6"
| "iso_646.irv:1991" | "csascii" => Encoding::Ascii,
"iso-8859-1" | "latin1" | "iso8859-1" | "iso_8859-1" | "iso_8859-1:1987"
| "windows-1252" | "cp1252" | "cp819" | "csisolatin1" | "ibm819" | "l1"
| "x-cp1252" => Encoding::Windows1252,
"utf-16" | "utf-16le" | "csunicode" | "ucs-2" | "unicode" | "unicodefeff" => {
Encoding::Utf16Le
}
"utf-16be" | "unicodefffe" => Encoding::Utf16Be,
"utf-32" | "utf-32le" | "ucs-4" | "ucs-4le" | "ucs4" => Encoding::Utf32Le,
"utf-32be" | "ucs-4be" => Encoding::Utf32Be,
"ibm037" | "cp037" | "csibm037" => Encoding::Ebcdic037,
_ => Encoding::Other(trimmed),
}
}
fn starts_with_ascii(haystack: &[u8], needle: &[u8]) -> bool {
haystack.starts_with(needle)
}
fn starts_with_ascii_ignore_case(haystack: &[u8], needle: &[u8]) -> bool {
if haystack.len() < needle.len() {
return false;
}
haystack[..needle.len()]
.iter()
.zip(needle)
.all(|(a, b)| a.to_ascii_lowercase() == b.to_ascii_lowercase())
}
fn ascii_equal_ignore_case(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len()
&& a.iter()
.zip(b)
.all(|(x, y)| x.to_ascii_lowercase() == y.to_ascii_lowercase())
}
fn find_byte(haystack: &[u8], needle: u8) -> Option<usize> {
memchr::memchr(needle, haystack)
}
#[cfg(test)]
mod tests {
use super::*;
fn opts() -> HtmlParseOptions {
HtmlParseOptions::default()
}
#[test]
fn bom_utf8() {
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(b"<html></html>");
assert_eq!(sniff_html_encoding(&bytes, &opts()), Encoding::Utf8);
}
#[test]
fn bom_utf16le() {
let bytes = [0xFF, 0xFE, b'<', 0, b'a', 0];
assert_eq!(sniff_html_encoding(&bytes, &opts()), Encoding::Utf16Le);
}
#[test]
fn bom_utf16be() {
let bytes = [0xFE, 0xFF, 0, b'<', 0, b'a'];
assert_eq!(sniff_html_encoding(&bytes, &opts()), Encoding::Utf16Be);
}
#[test]
fn meta_charset_simple() {
let html = br#"<!DOCTYPE html><html><head><meta charset="UTF-8"></head></html>"#;
assert_eq!(prescan_meta_charset(html), Some("UTF-8".into()));
}
#[test]
fn meta_charset_unquoted() {
let html = br"<meta charset=UTF-8>";
assert_eq!(prescan_meta_charset(html), Some("UTF-8".into()));
}
#[test]
fn meta_charset_single_quotes() {
let html = br"<meta charset='windows-1252'>";
assert_eq!(prescan_meta_charset(html), Some("windows-1252".into()));
}
#[test]
fn meta_charset_case_insensitive() {
let html = br#"<META CHARSET="ISO-8859-1">"#;
assert_eq!(prescan_meta_charset(html), Some("ISO-8859-1".into()));
}
#[test]
fn meta_http_equiv_content_type() {
let html = br#"<meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS">"#;
assert_eq!(prescan_meta_charset(html), Some("Shift_JIS".into()));
}
#[test]
fn meta_http_equiv_quoted_charset() {
let html =
br#"<meta http-equiv="content-type" content='text/html;charset="EUC-KR"'>"#;
assert_eq!(prescan_meta_charset(html), Some("EUC-KR".into()));
}
#[test]
fn meta_inside_comment_ignored() {
let html =
br#"<!-- <meta charset="UTF-8"> --><meta charset="ISO-8859-1">"#;
assert_eq!(prescan_meta_charset(html), Some("ISO-8859-1".into()));
}
#[test]
fn no_meta_falls_back_to_windows1252() {
let html = br"<html><head><title>x</title></head><body>x</body></html>";
assert_eq!(sniff_html_encoding(html, &opts()), Encoding::Windows1252);
}
#[test]
fn override_wins_over_bom() {
let mut bytes = vec![0xEF, 0xBB, 0xBF];
bytes.extend_from_slice(b"<html></html>");
let mut o = opts();
o.encoding_override = Some("ISO-8859-1".into());
assert_eq!(sniff_html_encoding(&bytes, &o), Encoding::Windows1252);
}
#[test]
fn label_iso88591_is_windows1252() {
assert_eq!(label_to_encoding("iso-8859-1"), Encoding::Windows1252);
assert_eq!(label_to_encoding("ISO-8859-1"), Encoding::Windows1252);
assert_eq!(label_to_encoding(" Latin1 "), Encoding::Windows1252);
}
#[test]
fn label_unknown_routes_to_other() {
match label_to_encoding("shift_jis") {
Encoding::Other(name) => assert_eq!(name, "shift_jis"),
_ => panic!("expected Other"),
}
}
#[test]
fn decode_with_meta_windows1252_content() {
let mut bytes = b"<meta charset=\"windows-1252\"><body>".to_vec();
bytes.push(0x85);
bytes.extend_from_slice(b"</body>");
let (decoded, enc) = decode_html_input(&bytes, &opts()).unwrap();
assert_eq!(enc, Encoding::Windows1252);
let s = std::str::from_utf8(&decoded).expect("decoded must be UTF-8");
assert!(s.contains('\u{2026}'), "ellipsis should be decoded: {s:?}");
}
#[test]
fn decode_no_signal_uses_windows1252() {
let bytes = b"<html><body>plain</body></html>";
let (_, enc) = decode_html_input(bytes, &opts()).unwrap();
assert_eq!(enc, Encoding::Windows1252);
}
}