1pub(crate) fn has_valid_alt(tag: &str) -> bool {
12 let has_alt_eq = tag.contains("alt=");
13 let has_alt_bare = !has_alt_eq
14 && (tag.contains(" alt ")
15 || tag.contains(" alt>")
16 || tag.ends_with(" alt"));
17 has_alt_eq || has_alt_bare
18}
19
20pub(crate) fn has_empty_alt(tag: &str) -> bool {
22 let has_alt_eq = tag.contains("alt=");
23 let has_alt_bare = !has_alt_eq
24 && (tag.contains(" alt ")
25 || tag.contains(" alt>")
26 || tag.ends_with(" alt"));
27 tag.contains("alt=\"\"")
28 || tag.contains("alt=''")
29 || has_alt_bare
30 || (has_alt_eq && !tag.contains("alt=\"") && !tag.contains("alt='"))
31}
32
33pub(crate) fn is_decorative_img(tag: &str) -> bool {
35 tag.contains("role=\"presentation\"")
36 || tag.contains("role=\"none\"")
37 || tag.contains("role='presentation'")
38 || tag.contains("role='none'")
39 || tag.contains("role=presentation")
40 || tag.contains("role=none")
41}
42
43pub const fn find_tag_end(html: &str, tag_start: usize) -> usize {
52 let bytes = html.as_bytes();
53 let mut i = tag_start;
54 let mut quote: Option<u8> = None;
55 while i < bytes.len() {
56 let b = bytes[i];
57 match quote {
58 Some(q) if b == q => quote = None,
59 Some(_) => {}
60 None => match b {
61 b'"' | b'\'' => quote = Some(b),
62 b'>' => return i + 1,
63 _ => {}
64 },
65 }
66 i += 1;
67 }
68 bytes.len()
69}
70
71pub(crate) fn extract_attr_value(tag: &str, attr: &str) -> Option<String> {
73 let lower = tag.to_ascii_lowercase();
74 let pattern = format!("{attr}=");
75 let start = lower.find(&pattern)?;
76 let after = &tag[start + pattern.len()..];
77 let trimmed = after.trim_start();
78 if let Some(inner) = trimmed.strip_prefix('"') {
79 let end = inner.find('"')?;
80 Some(inner[..end].to_string())
81 } else if let Some(inner) = trimmed.strip_prefix('\'') {
82 let end = inner.find('\'')?;
83 Some(inner[..end].to_string())
84 } else {
85 let end = trimmed
86 .find(|c: char| c.is_whitespace() || c == '>')
87 .unwrap_or(trimmed.len());
88 Some(trimmed[..end].to_string())
89 }
90}
91
92pub(crate) fn strip_tags_simple(html: &str) -> String {
94 let mut result = String::with_capacity(html.len());
95 let mut in_tag = false;
96 for ch in html.chars() {
97 if ch == '<' {
98 in_tag = true;
99 } else if ch == '>' {
100 in_tag = false;
101 } else if !in_tag {
102 result.push(ch);
103 }
104 }
105 result
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn extract_attr_value_double_quoted() {
114 let result = extract_attr_value(r#"<a href="/foo">"#, "href");
115 assert_eq!(result, Some("/foo".to_string()));
116 }
117
118 #[test]
119 fn extract_attr_value_single_quoted() {
120 let result = extract_attr_value(r"<a href='/bar'>", "href");
122 assert_eq!(result, Some("/bar".to_string()));
123 }
124
125 #[test]
126 fn extract_attr_value_unquoted() {
127 let result = extract_attr_value(r"<a href=/baz>", "href");
129 assert_eq!(result, Some("/baz".to_string()));
130 }
131
132 #[test]
133 fn extract_attr_value_missing_attribute_returns_none() {
134 let result = extract_attr_value(r"<a>", "href");
135 assert!(result.is_none());
136 }
137
138 #[test]
139 fn strip_tags_simple_removes_html_tags_and_preserves_text() {
140 let result = strip_tags_simple("<p>hello <b>world</b>!</p>");
141 assert_eq!(result, "hello world!");
142 }
143
144 #[test]
145 fn strip_tags_simple_handles_empty_and_text_only() {
146 assert_eq!(strip_tags_simple(""), "");
147 assert_eq!(strip_tags_simple("plain text"), "plain text");
148 }
149
150 #[test]
151 fn has_empty_alt_detects_bare_alt_attribute() {
152 assert!(has_empty_alt("<img src=x alt>"));
154 assert!(has_empty_alt("<img alt src=x>"));
155 assert!(has_empty_alt("<img src=x alt"));
157 }
158
159 #[test]
160 fn has_empty_alt_detects_unquoted_missing_value() {
161 assert!(has_empty_alt("<img alt=>"));
163 assert!(!has_empty_alt("<img alt='photo'>"));
165 }
166
167 #[test]
168 fn is_decorative_img_covers_all_role_spellings() {
169 assert!(is_decorative_img("<img role=\"presentation\">"));
170 assert!(is_decorative_img("<img role=\"none\">"));
171 assert!(is_decorative_img("<img role='presentation'>"));
172 assert!(is_decorative_img("<img role='none'>"));
173 assert!(is_decorative_img("<img role=presentation>"));
174 assert!(is_decorative_img("<img role=none>"));
175 assert!(!is_decorative_img("<img role=\"img\">"));
176 }
177
178 #[test]
179 fn find_tag_end_without_closing_bracket_returns_len() {
180 let html = "<img src=\"unterminated";
181 assert_eq!(find_tag_end(html, 0), html.len());
182 }
183
184 #[test]
185 fn test_extract_attr_value_quoting_styles() {
186 assert_eq!(
187 extract_attr_value("<img alt=\"hello\">", "alt"),
188 Some("hello".to_string())
189 );
190 assert_eq!(
191 extract_attr_value("<img alt='single'>", "alt"),
192 Some("single".to_string())
193 );
194 assert_eq!(
195 extract_attr_value("<img alt=unquoted>", "alt"),
196 Some("unquoted".to_string())
197 );
198 assert_eq!(
199 extract_attr_value("<img alt=unquoted-space class=x>", "alt"),
200 Some("unquoted-space".to_string())
201 );
202 assert_eq!(extract_attr_value("<img alt=\"unclosed>", "alt"), None);
204 assert_eq!(extract_attr_value("<img alt='unclosed>", "alt"), None);
205 assert_eq!(extract_attr_value("<img alt=\"hello\">", "width"), None);
207 }
208}