1#![forbid(unsafe_code)]
2
3use std::borrow::Cow;
33
34use crate::encoding::{transcode_to_utf8_as, Encoding};
35use crate::error::Result;
36
37use super::options::HtmlParseOptions;
38
39pub fn sniff_html_encoding(bytes: &[u8], opts: &HtmlParseOptions) -> Encoding {
43 if let Some(label) = opts.encoding_override.as_deref() {
45 return label_to_encoding(label);
46 }
47
48 if let Some(enc) = sniff_bom(bytes) {
50 return enc;
51 }
52
53 let window = opts.encoding_sniff_window.max(64).min(bytes.len());
55 if let Some(label) = prescan_meta_charset(&bytes[..window]) {
56 return label_to_encoding(&label);
57 }
58
59 Encoding::Windows1252
61}
62
63pub fn decode_html_input<'a>(
67 bytes: &'a [u8],
68 opts: &HtmlParseOptions,
69) -> Result<(Cow<'a, [u8]>, Encoding)> {
70 let enc = sniff_html_encoding(bytes, opts);
71 let decoded = transcode_to_utf8_as(bytes, enc.clone())?;
72 Ok((decoded, enc))
73}
74
75fn sniff_bom(bytes: &[u8]) -> Option<Encoding> {
78 if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
79 return Some(Encoding::Utf8);
80 }
81 if bytes.starts_with(&[0xFE, 0xFF]) {
82 return Some(Encoding::Utf16Be);
83 }
84 if bytes.starts_with(&[0xFF, 0xFE]) {
85 return Some(Encoding::Utf16Le);
86 }
87 None
88}
89
90pub fn prescan_meta_charset(bytes: &[u8]) -> Option<String> {
100 let mut pos = 0;
101 while pos < bytes.len() {
102 let b = bytes[pos];
103
104 if starts_with_ascii(&bytes[pos..], b"<!--") {
106 pos += 4;
107 while pos + 2 < bytes.len()
109 && !(bytes[pos] == b'-' && bytes[pos + 1] == b'-' && bytes[pos + 2] == b'>')
110 {
111 pos += 1;
112 }
113 pos = (pos + 3).min(bytes.len());
114 continue;
115 }
116
117 if starts_with_ascii_ignore_case(&bytes[pos..], b"<meta")
119 && bytes
120 .get(pos + 5)
121 .is_some_and(|&c| matches!(c, b' ' | b'\t' | b'\n' | b'\r' | 0x0C | b'/' | b'>'))
122 {
123 pos += 5;
124 if let Some(label) = parse_meta_attrs(bytes, &mut pos) {
125 return Some(label);
126 }
127 continue;
129 }
130
131 if b == b'<' {
133 if let Some(end) = find_byte(&bytes[pos..], b'>') {
135 pos += end + 1;
136 continue;
137 }
138 return None;
140 }
141
142 pos += 1;
143 }
144 None
145}
146
147fn parse_meta_attrs(bytes: &[u8], pos: &mut usize) -> Option<String> {
151 let mut http_equiv: Option<Vec<u8>> = None;
152 let mut content: Option<Vec<u8>> = None;
153 let mut charset: Option<String> = None;
154
155 loop {
156 while *pos < bytes.len()
158 && matches!(bytes[*pos], b' ' | b'\t' | b'\n' | b'\r' | 0x0C | b'/')
159 {
160 *pos += 1;
161 }
162 if *pos >= bytes.len() {
163 break;
164 }
165 if bytes[*pos] == b'>' {
166 *pos += 1;
167 break;
168 }
169
170 let mut name = Vec::new();
172 while *pos < bytes.len() {
173 let c = bytes[*pos];
174 if matches!(c, b'=' | b' ' | b'\t' | b'\n' | b'\r' | 0x0C | b'/' | b'>') {
175 break;
176 }
177 name.push(c.to_ascii_lowercase());
178 *pos += 1;
179 }
180 if name.is_empty() {
181 if *pos < bytes.len() && bytes[*pos] == b'>' {
183 *pos += 1;
184 break;
185 }
186 *pos += 1;
187 continue;
188 }
189
190 while *pos < bytes.len() && matches!(bytes[*pos], b' ' | b'\t' | b'\n' | b'\r' | 0x0C) {
192 *pos += 1;
193 }
194
195 let mut value: Vec<u8> = Vec::new();
196 if *pos < bytes.len() && bytes[*pos] == b'=' {
197 *pos += 1;
198 while *pos < bytes.len()
200 && matches!(bytes[*pos], b' ' | b'\t' | b'\n' | b'\r' | 0x0C)
201 {
202 *pos += 1;
203 }
204 if *pos < bytes.len() && (bytes[*pos] == b'"' || bytes[*pos] == b'\'') {
205 let quote = bytes[*pos];
206 *pos += 1;
207 while *pos < bytes.len() && bytes[*pos] != quote {
208 value.push(bytes[*pos]);
209 *pos += 1;
210 }
211 if *pos < bytes.len() {
212 *pos += 1; }
214 } else {
215 while *pos < bytes.len() {
216 let c = bytes[*pos];
217 if matches!(c, b' ' | b'\t' | b'\n' | b'\r' | 0x0C | b'>') {
218 break;
219 }
220 value.push(c);
221 *pos += 1;
222 }
223 }
224 }
225
226 match name.as_slice() {
228 b"charset" => {
229 if charset.is_none() {
230 charset = Some(String::from_utf8_lossy(&value).into_owned());
231 }
232 }
233 b"http-equiv" => {
234 if http_equiv.is_none() {
235 http_equiv = Some(value);
236 }
237 }
238 b"content" => {
239 if content.is_none() {
240 content = Some(value);
241 }
242 }
243 _ => {}
244 }
245 }
246
247 if let Some(c) = charset {
249 return Some(c);
250 }
251 if let (Some(equiv), Some(content_val)) = (http_equiv, content) {
253 if ascii_equal_ignore_case(&equiv, b"content-type") {
254 return extract_charset_from_content(&content_val);
255 }
256 }
257 None
258}
259
260fn extract_charset_from_content(content: &[u8]) -> Option<String> {
265 let lower: Vec<u8> = content.iter().map(|b| b.to_ascii_lowercase()).collect();
266 let needle = b"charset=";
267 let pos = lower.windows(needle.len()).position(|w| w == needle)?;
268 let mut start = pos + needle.len();
269 if start < content.len() && (content[start] == b'"' || content[start] == b'\'') {
270 let quote = content[start];
271 start += 1;
272 let end = content[start..].iter().position(|&c| c == quote)?;
273 Some(String::from_utf8_lossy(&content[start..start + end]).into_owned())
274 } else {
275 let end = content[start..]
276 .iter()
277 .position(|&c| matches!(c, b';' | b' ' | b'\t' | b'\n' | b'\r' | 0x0C))
278 .unwrap_or(content.len() - start);
279 Some(String::from_utf8_lossy(&content[start..start + end]).into_owned())
280 }
281}
282
283pub fn label_to_encoding(label: &str) -> Encoding {
293 let trimmed = label.trim().to_ascii_lowercase();
294 match trimmed.as_str() {
295 "utf-8" | "utf8" | "unicode-1-1-utf-8" | "unicode11utf8" | "unicode20utf8"
296 | "x-unicode20utf8" => Encoding::Utf8,
297 "us-ascii" | "ascii" | "ansi_x3.4-1968" | "iso646-us" | "iso-ir-6"
298 | "iso_646.irv:1991" | "csascii" => Encoding::Ascii,
299 "iso-8859-1" | "latin1" | "iso8859-1" | "iso_8859-1" | "iso_8859-1:1987"
301 | "windows-1252" | "cp1252" | "cp819" | "csisolatin1" | "ibm819" | "l1"
302 | "x-cp1252" => Encoding::Windows1252,
303 "utf-16" | "utf-16le" | "csunicode" | "ucs-2" | "unicode" | "unicodefeff" => {
304 Encoding::Utf16Le
305 }
306 "utf-16be" | "unicodefffe" => Encoding::Utf16Be,
307 "utf-32" | "utf-32le" | "ucs-4" | "ucs-4le" | "ucs4" => Encoding::Utf32Le,
311 "utf-32be" | "ucs-4be" => Encoding::Utf32Be,
312 "ibm037" | "cp037" | "csibm037" => Encoding::Ebcdic037,
313 _ => Encoding::Other(trimmed),
315 }
316}
317
318fn starts_with_ascii(haystack: &[u8], needle: &[u8]) -> bool {
321 haystack.starts_with(needle)
322}
323
324fn starts_with_ascii_ignore_case(haystack: &[u8], needle: &[u8]) -> bool {
325 if haystack.len() < needle.len() {
326 return false;
327 }
328 haystack[..needle.len()]
329 .iter()
330 .zip(needle)
331 .all(|(a, b)| a.to_ascii_lowercase() == b.to_ascii_lowercase())
332}
333
334fn ascii_equal_ignore_case(a: &[u8], b: &[u8]) -> bool {
335 a.len() == b.len()
336 && a.iter()
337 .zip(b)
338 .all(|(x, y)| x.to_ascii_lowercase() == y.to_ascii_lowercase())
339}
340
341fn find_byte(haystack: &[u8], needle: u8) -> Option<usize> {
342 memchr::memchr(needle, haystack)
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 fn opts() -> HtmlParseOptions {
350 HtmlParseOptions::default()
351 }
352
353 #[test]
354 fn bom_utf8() {
355 let mut bytes = vec![0xEF, 0xBB, 0xBF];
356 bytes.extend_from_slice(b"<html></html>");
357 assert_eq!(sniff_html_encoding(&bytes, &opts()), Encoding::Utf8);
358 }
359
360 #[test]
361 fn bom_utf16le() {
362 let bytes = [0xFF, 0xFE, b'<', 0, b'a', 0];
363 assert_eq!(sniff_html_encoding(&bytes, &opts()), Encoding::Utf16Le);
364 }
365
366 #[test]
367 fn bom_utf16be() {
368 let bytes = [0xFE, 0xFF, 0, b'<', 0, b'a'];
369 assert_eq!(sniff_html_encoding(&bytes, &opts()), Encoding::Utf16Be);
370 }
371
372 #[test]
373 fn meta_charset_simple() {
374 let html = br#"<!DOCTYPE html><html><head><meta charset="UTF-8"></head></html>"#;
375 assert_eq!(prescan_meta_charset(html), Some("UTF-8".into()));
376 }
377
378 #[test]
379 fn meta_charset_unquoted() {
380 let html = br"<meta charset=UTF-8>";
381 assert_eq!(prescan_meta_charset(html), Some("UTF-8".into()));
382 }
383
384 #[test]
385 fn meta_charset_single_quotes() {
386 let html = br"<meta charset='windows-1252'>";
387 assert_eq!(prescan_meta_charset(html), Some("windows-1252".into()));
388 }
389
390 #[test]
391 fn meta_charset_case_insensitive() {
392 let html = br#"<META CHARSET="ISO-8859-1">"#;
393 assert_eq!(prescan_meta_charset(html), Some("ISO-8859-1".into()));
394 }
395
396 #[test]
397 fn meta_http_equiv_content_type() {
398 let html = br#"<meta http-equiv="Content-Type" content="text/html; charset=Shift_JIS">"#;
399 assert_eq!(prescan_meta_charset(html), Some("Shift_JIS".into()));
400 }
401
402 #[test]
403 fn meta_http_equiv_quoted_charset() {
404 let html =
405 br#"<meta http-equiv="content-type" content='text/html;charset="EUC-KR"'>"#;
406 assert_eq!(prescan_meta_charset(html), Some("EUC-KR".into()));
407 }
408
409 #[test]
410 fn meta_inside_comment_ignored() {
411 let html =
412 br#"<!-- <meta charset="UTF-8"> --><meta charset="ISO-8859-1">"#;
413 assert_eq!(prescan_meta_charset(html), Some("ISO-8859-1".into()));
414 }
415
416 #[test]
417 fn no_meta_falls_back_to_windows1252() {
418 let html = br"<html><head><title>x</title></head><body>x</body></html>";
419 assert_eq!(sniff_html_encoding(html, &opts()), Encoding::Windows1252);
420 }
421
422 #[test]
423 fn override_wins_over_bom() {
424 let mut bytes = vec![0xEF, 0xBB, 0xBF];
425 bytes.extend_from_slice(b"<html></html>");
426 let mut o = opts();
427 o.encoding_override = Some("ISO-8859-1".into());
428 assert_eq!(sniff_html_encoding(&bytes, &o), Encoding::Windows1252);
429 }
430
431 #[test]
432 fn label_iso88591_is_windows1252() {
433 assert_eq!(label_to_encoding("iso-8859-1"), Encoding::Windows1252);
434 assert_eq!(label_to_encoding("ISO-8859-1"), Encoding::Windows1252);
435 assert_eq!(label_to_encoding(" Latin1 "), Encoding::Windows1252);
436 }
437
438 #[test]
439 fn label_unknown_routes_to_other() {
440 match label_to_encoding("shift_jis") {
441 Encoding::Other(name) => assert_eq!(name, "shift_jis"),
442 _ => panic!("expected Other"),
443 }
444 }
445
446 #[test]
447 fn decode_with_meta_windows1252_content() {
448 let mut bytes = b"<meta charset=\"windows-1252\"><body>".to_vec();
452 bytes.push(0x85);
453 bytes.extend_from_slice(b"</body>");
454 let (decoded, enc) = decode_html_input(&bytes, &opts()).unwrap();
455 assert_eq!(enc, Encoding::Windows1252);
456 let s = std::str::from_utf8(&decoded).expect("decoded must be UTF-8");
457 assert!(s.contains('\u{2026}'), "ellipsis should be decoded: {s:?}");
458 }
459
460 #[test]
461 fn decode_no_signal_uses_windows1252() {
462 let bytes = b"<html><body>plain</body></html>";
464 let (_, enc) = decode_html_input(bytes, &opts()).unwrap();
465 assert_eq!(enc, Encoding::Windows1252);
466 }
467}