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=\"\"")
30 || tag.contains("alt=''")
31 || has_alt_bare
32 || (has_alt_eq
33 && extract_attr_value(tag, "alt").is_some_and(|v| v.is_empty()))
34}
35
36pub(crate) fn is_decorative_img(tag: &str) -> bool {
40 let role = extract_attr_value(tag, "role");
41 matches!(role.as_deref(), Some("presentation" | "none"))
42 || extract_attr_value(tag, "aria-hidden").as_deref() == Some("true")
43}
44
45#[must_use]
54pub fn img_missing_alt(tag: &str) -> bool {
55 !has_valid_alt(tag) || (has_empty_alt(tag) && !is_decorative_img(tag))
56}
57
58pub const fn find_tag_end(html: &str, tag_start: usize) -> usize {
67 let bytes = html.as_bytes();
68 let mut i = tag_start;
69 let mut quote: Option<u8> = None;
70 while i < bytes.len() {
71 let b = bytes[i];
72 match quote {
73 Some(q) if b == q => quote = None,
74 Some(_) => {}
75 None => match b {
76 b'"' | b'\'' => quote = Some(b),
77 b'>' => return i + 1,
78 _ => {}
79 },
80 }
81 i += 1;
82 }
83 bytes.len()
84}
85
86pub(crate) fn extract_attr_value(tag: &str, attr: &str) -> Option<String> {
88 let lower = tag.to_ascii_lowercase();
89 let pattern = format!("{attr}=");
90 let start = lower.find(&pattern)?;
91 let after = &tag[start + pattern.len()..];
92 let trimmed = after.trim_start();
93 if let Some(inner) = trimmed.strip_prefix('"') {
94 let end = inner.find('"')?;
95 Some(inner[..end].to_string())
96 } else if let Some(inner) = trimmed.strip_prefix('\'') {
97 let end = inner.find('\'')?;
98 Some(inner[..end].to_string())
99 } else {
100 let end = trimmed
101 .find(|c: char| c.is_whitespace() || c == '>')
102 .unwrap_or(trimmed.len());
103 Some(trimmed[..end].to_string())
104 }
105}
106
107pub(crate) fn strip_tags_simple(html: &str) -> String {
109 let mut result = String::with_capacity(html.len());
110 let mut in_tag = false;
111 for ch in html.chars() {
112 if ch == '<' {
113 in_tag = true;
114 } else if ch == '>' {
115 in_tag = false;
116 } else if !in_tag {
117 result.push(ch);
118 }
119 }
120 result
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[test]
128 fn extract_attr_value_double_quoted() {
129 let result = extract_attr_value(r#"<a href="/foo">"#, "href");
130 assert_eq!(result, Some("/foo".to_string()));
131 }
132
133 #[test]
134 fn extract_attr_value_single_quoted() {
135 let result = extract_attr_value(r"<a href='/bar'>", "href");
137 assert_eq!(result, Some("/bar".to_string()));
138 }
139
140 #[test]
141 fn extract_attr_value_unquoted() {
142 let result = extract_attr_value(r"<a href=/baz>", "href");
144 assert_eq!(result, Some("/baz".to_string()));
145 }
146
147 #[test]
148 fn extract_attr_value_missing_attribute_returns_none() {
149 let result = extract_attr_value(r"<a>", "href");
150 assert!(result.is_none());
151 }
152
153 #[test]
154 fn strip_tags_simple_removes_html_tags_and_preserves_text() {
155 let result = strip_tags_simple("<p>hello <b>world</b>!</p>");
156 assert_eq!(result, "hello world!");
157 }
158
159 #[test]
160 fn strip_tags_simple_handles_empty_and_text_only() {
161 assert_eq!(strip_tags_simple(""), "");
162 assert_eq!(strip_tags_simple("plain text"), "plain text");
163 }
164
165 #[test]
166 fn has_empty_alt_detects_bare_alt_attribute() {
167 assert!(has_empty_alt("<img src=x alt>"));
169 assert!(has_empty_alt("<img alt src=x>"));
170 assert!(has_empty_alt("<img src=x alt"));
172 }
173
174 #[test]
175 fn has_empty_alt_detects_unquoted_missing_value() {
176 assert!(has_empty_alt("<img alt=>"));
178 assert!(!has_empty_alt("<img alt='photo'>"));
180 }
181
182 #[test]
183 fn is_decorative_img_covers_all_role_spellings() {
184 assert!(is_decorative_img("<img role=\"presentation\">"));
185 assert!(is_decorative_img("<img role=\"none\">"));
186 assert!(is_decorative_img("<img role='presentation'>"));
187 assert!(is_decorative_img("<img role='none'>"));
188 assert!(is_decorative_img("<img role=presentation>"));
189 assert!(is_decorative_img("<img role=none>"));
190 assert!(!is_decorative_img("<img role=\"img\">"));
191 }
192
193 #[test]
194 fn is_decorative_img_accepts_aria_hidden_true() {
195 assert!(is_decorative_img("<img aria-hidden=\"true\">"));
198 assert!(is_decorative_img("<img aria-hidden='true'>"));
199 assert!(is_decorative_img("<img aria-hidden=true>"));
200 assert!(!is_decorative_img("<img aria-hidden=\"false\">"));
201 assert!(!is_decorative_img("<img src=\"a.png\">"));
202 }
203
204 #[test]
205 fn has_empty_alt_accepts_unquoted_value() {
206 assert!(!has_empty_alt("<img alt=photo src=x>"));
208 assert!(!has_empty_alt("<img src=x alt=photo>"));
209 }
210
211 #[test]
212 fn find_tag_end_without_closing_bracket_returns_len() {
213 let html = "<img src=\"unterminated";
214 assert_eq!(find_tag_end(html, 0), html.len());
215 }
216
217 #[test]
218 fn test_extract_attr_value_quoting_styles() {
219 assert_eq!(
220 extract_attr_value("<img alt=\"hello\">", "alt"),
221 Some("hello".to_string())
222 );
223 assert_eq!(
224 extract_attr_value("<img alt='single'>", "alt"),
225 Some("single".to_string())
226 );
227 assert_eq!(
228 extract_attr_value("<img alt=unquoted>", "alt"),
229 Some("unquoted".to_string())
230 );
231 assert_eq!(
232 extract_attr_value("<img alt=unquoted-space class=x>", "alt"),
233 Some("unquoted-space".to_string())
234 );
235 assert_eq!(extract_attr_value("<img alt=\"unclosed>", "alt"), None);
237 assert_eq!(extract_attr_value("<img alt='unclosed>", "alt"), None);
238 assert_eq!(extract_attr_value("<img alt=\"hello\">", "width"), None);
240 }
241}