use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct HtmlImageInfo {
pub hash_name: String,
pub alt_text: Option<String>,
}
pub fn image_hash_name(bytes: &[u8], ext: &str) -> Option<String> {
let supported = matches!(ext, "png" | "jpg" | "jpeg" | "gif" | "webp");
if !supported {
return None;
}
let mut hasher = DefaultHasher::new();
bytes.hash(&mut hasher);
Some(format!("{:016x}.{ext}", hasher.finish()))
}
pub fn get_tag_attribute(tag: &str, attr_name: &str) -> Option<String> {
let lower_tag = tag.to_ascii_lowercase();
let lower_attr = attr_name.to_ascii_lowercase();
for quote in ['"', '\''] {
let pattern = format!("{lower_attr}={quote}");
if let Some(pos) = lower_tag.find(&pattern) {
let value_start = pos + pattern.len();
if let Some(end_offset) = tag[value_start..].find(quote) {
return Some(tag[value_start..value_start + end_offset].to_string());
}
}
}
None
}
fn data_uri_ext(mime: &str) -> Option<&'static str> {
match mime {
"image/png" => Some("png"),
"image/jpeg" => Some("jpeg"),
"image/jpg" => Some("jpg"),
"image/gif" => Some("gif"),
"image/webp" => Some("webp"),
_ => None,
}
}
fn decode_data_uri(src: &str) -> Option<(Vec<u8>, &'static str)> {
use base64::{engine::general_purpose::STANDARD, Engine};
let rest = src.strip_prefix("data:")?;
let (meta, data) = rest.split_once(',')?;
let (mime_part, encoding) = meta.split_once(';').unwrap_or((meta, ""));
if encoding != "base64" {
return None;
}
let ext = data_uri_ext(&mime_part.to_ascii_lowercase())?;
let bytes = STANDARD.decode(data.trim()).ok()?;
Some((bytes, ext))
}
fn resolve_local_file(src: &str, html_dir: &Path) -> Option<(Vec<u8>, String)> {
if src.starts_with("http://") || src.starts_with("https://") || src.starts_with("//") {
return None;
}
let img_path = if src.starts_with('/') {
Path::new(src).to_path_buf()
} else {
html_dir.join(src)
};
let ext = img_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())?;
if !matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp") {
return None;
}
let bytes = std::fs::read(&img_path).ok()?;
Some((bytes, ext))
}
pub fn collect_html_images(
html: &str,
file_path: &str,
image_out: &mut Vec<(String, Vec<u8>)>,
) -> Vec<HtmlImageInfo> {
let html_dir = Path::new(file_path)
.parent()
.unwrap_or_else(|| Path::new("."));
let mut result = Vec::new();
let mut i = 0usize;
while i < html.len() {
let Some(tag_start) = find_img_start(html, i) else { break };
let Some(tag_end) = find_tag_close(html, tag_start) else {
i = tag_start + 4;
continue;
};
let tag_slice = &html[tag_start..tag_end];
let Some(src) = get_tag_attribute(tag_slice, "src") else {
i = tag_end;
continue;
};
let alt = get_tag_attribute(tag_slice, "alt").filter(|s| !s.trim().is_empty());
let (img_bytes, ext): (Vec<u8>, String) = if src.starts_with("data:") {
match decode_data_uri(&src) {
Some((b, e)) => (b, e.to_string()),
None => { i = tag_end; continue; }
}
} else {
match resolve_local_file(&src, html_dir) {
Some(pair) => pair,
None => { i = tag_end; continue; }
}
};
let Some(hash_name) = image_hash_name(&img_bytes, &ext) else {
i = tag_end;
continue;
};
if !image_out.iter().any(|(n, _)| n == &hash_name) {
image_out.push((hash_name.clone(), img_bytes));
}
result.push(HtmlImageInfo { hash_name, alt_text: alt });
i = tag_end;
}
result
}
fn find_img_start(html: &str, from: usize) -> Option<usize> {
let lower = html[from..].to_ascii_lowercase();
lower.find("<img").map(|rel| from + rel)
}
fn find_tag_close(html: &str, start: usize) -> Option<usize> {
let bytes = html.as_bytes();
let mut i = start;
let mut in_quote: Option<u8> = None;
while i < bytes.len() {
match (in_quote, bytes[i]) {
(None, b'"') | (None, b'\'') => { in_quote = Some(bytes[i]); i += 1; }
(Some(q), c) if c == q => { in_quote = None; i += 1; }
(None, b'>') => return Some(i + 1),
_ => { i += 1; }
}
}
None
}