use anyhow::Result;
use super::{ContentHandler, ConversionResult};
pub struct ImageHandler;
impl ContentHandler for ImageHandler {
fn supported_types(&self) -> &[&str] {
&[
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
"image/bmp",
"image/x-icon",
"image/vnd.microsoft.icon",
"image/tiff",
"image/svg+xml",
]
}
fn to_markdown(&self, bytes: &[u8], content_type: &str) -> Result<ConversionResult> {
let start = std::time::Instant::now();
let markdown = describe_image(bytes, content_type);
Ok(ConversionResult {
markdown,
page_count: None,
content_type: content_type.to_string(),
elapsed_ms: start.elapsed().as_secs_f64() * 1000.0,
quality: None,
})
}
}
#[derive(Debug, PartialEq)]
struct ImageMeta {
format: &'static str,
dimensions: Option<(u32, u32)>,
label: Option<String>,
}
fn describe_image(bytes: &[u8], content_type: &str) -> String {
let meta = detect_image(bytes, content_type);
format_image_description(&meta, bytes.len())
}
fn detect_image(bytes: &[u8], content_type: &str) -> ImageMeta {
if is_png(bytes) {
return png_meta(bytes);
}
if is_jpeg(bytes) {
return jpeg_meta(bytes);
}
if is_gif(bytes) {
return gif_meta(bytes);
}
if is_webp(bytes) {
return webp_meta(bytes);
}
if is_bmp(bytes) {
return bmp_meta(bytes);
}
if is_ico(bytes) {
return ico_meta(bytes);
}
if is_tiff(bytes) {
return tiff_meta(bytes);
}
if is_svg(bytes) {
return svg_meta(bytes);
}
if is_isobmff(bytes) {
return isobmff_meta(bytes);
}
let format_hint = mime_to_format_hint(content_type);
ImageMeta {
format: format_hint,
dimensions: None,
label: None,
}
}
fn format_image_description(meta: &ImageMeta, byte_len: usize) -> String {
let mut parts = vec![meta.format.to_string()];
if let Some((w, h)) = meta.dimensions {
parts.push(format!("{w}×{h}"));
} else {
parts.push(format!("{byte_len} bytes"));
}
let base = format!("[Image: {}]", parts.join(" "));
match &meta.label {
Some(label) if !label.is_empty() => format!("{base}\n{label}"),
_ => base,
}
}
fn is_png(b: &[u8]) -> bool {
b.len() >= 8 && b[..8] == [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
}
fn is_jpeg(b: &[u8]) -> bool {
b.len() >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF
}
fn is_gif(b: &[u8]) -> bool {
b.len() >= 6 && (&b[..6] == b"GIF87a" || &b[..6] == b"GIF89a")
}
fn is_webp(b: &[u8]) -> bool {
b.len() >= 12 && &b[..4] == b"RIFF" && &b[8..12] == b"WEBP"
}
fn is_bmp(b: &[u8]) -> bool {
b.len() >= 2 && &b[..2] == b"BM"
}
fn is_ico(b: &[u8]) -> bool {
b.len() >= 4 && b[0] == 0x00 && b[1] == 0x00 && b[2] == 0x01 && b[3] == 0x00
}
fn is_tiff(b: &[u8]) -> bool {
b.len() >= 4
&& ((&b[..4] == b"II\x2A\x00") || (&b[..4] == b"MM\x00\x2A")) }
fn is_svg(b: &[u8]) -> bool {
let head = std::str::from_utf8(&b[..b.len().min(256)]).unwrap_or("");
head.contains("<svg") || head.starts_with("<?xml")
}
fn is_isobmff(b: &[u8]) -> bool {
if b.len() < 12 {
return false;
}
&b[4..8] == b"ftyp"
}
fn png_meta(b: &[u8]) -> ImageMeta {
let dims = (b.len() >= 24).then(|| {
let w = read_u32_be(b, 16);
let h = read_u32_be(b, 20);
(w, h)
});
ImageMeta {
format: "PNG",
dimensions: dims,
label: None,
}
}
fn jpeg_meta(b: &[u8]) -> ImageMeta {
let dims = scan_jpeg_dimensions(b);
ImageMeta {
format: "JPEG",
dimensions: dims,
label: None,
}
}
fn scan_jpeg_dimensions(b: &[u8]) -> Option<(u32, u32)> {
let mut i = 2; while i + 4 <= b.len() {
if b[i] != 0xFF {
break;
}
let marker = b[i + 1];
if marker == 0xD9 {
break; }
if matches!(
marker,
0xC0 | 0xC1
| 0xC2
| 0xC3
| 0xC5
| 0xC6
| 0xC7
| 0xC9
| 0xCA
| 0xCB
| 0xCD
| 0xCE
| 0xCF
) {
if i + 9 <= b.len() {
let h = u32::from(read_u16_be(b, i + 5));
let w = u32::from(read_u16_be(b, i + 7));
if w > 0 && h > 0 {
return Some((w, h));
}
}
}
if i + 4 > b.len() {
break;
}
let seg_len = usize::from(read_u16_be(b, i + 2));
i += 2 + seg_len;
}
None
}
fn gif_meta(b: &[u8]) -> ImageMeta {
let dims = (b.len() >= 10).then(|| {
let w = u32::from(read_u16_le(b, 6));
let h = u32::from(read_u16_le(b, 8));
(w, h)
});
ImageMeta {
format: "GIF",
dimensions: dims,
label: None,
}
}
fn webp_meta(b: &[u8]) -> ImageMeta {
let dims = parse_webp_dimensions(b);
ImageMeta {
format: "WebP",
dimensions: dims,
label: None,
}
}
fn parse_webp_dimensions(b: &[u8]) -> Option<(u32, u32)> {
if b.len() < 30 {
return None;
}
let chunk_type = &b[12..16];
match chunk_type {
b"VP8 " => {
if b.len() >= 30 {
let w = u32::from(read_u16_le(b, 26) & 0x3FFF);
let h = u32::from(read_u16_le(b, 28) & 0x3FFF);
Some((w, h))
} else {
None
}
}
b"VP8L" => {
if b.len() >= 25 {
let bits = read_u32_le(b, 21);
let w = (bits & 0x3FFF) + 1;
let h = ((bits >> 14) & 0x3FFF) + 1;
Some((w, h))
} else {
None
}
}
b"VP8X" => {
if b.len() >= 30 {
let w = read_u24_le(b, 24) + 1;
let h = read_u24_le(b, 27) + 1;
Some((w, h))
} else {
None
}
}
_ => None,
}
}
fn bmp_meta(b: &[u8]) -> ImageMeta {
let dims = (b.len() >= 26).then(|| {
let w = read_u32_le(b, 18);
let h = read_u32_le(b, 22).cast_signed().unsigned_abs(); (w, h)
});
ImageMeta {
format: "BMP",
dimensions: dims,
label: None,
}
}
fn ico_meta(b: &[u8]) -> ImageMeta {
let dims = (b.len() >= 8).then(|| {
let w = if b[6] == 0 { 256u32 } else { u32::from(b[6]) };
let h = if b[7] == 0 { 256u32 } else { u32::from(b[7]) };
(w, h)
});
ImageMeta {
format: "ICO",
dimensions: dims,
label: None,
}
}
fn tiff_meta(b: &[u8]) -> ImageMeta {
let dims = parse_tiff_dimensions(b);
ImageMeta {
format: "TIFF",
dimensions: dims,
label: None,
}
}
fn parse_tiff_dimensions(b: &[u8]) -> Option<(u32, u32)> {
if b.len() < 8 {
return None;
}
let little_endian = &b[..2] == b"II";
let u16 = |off: usize| -> Option<u32> {
if off + 2 > b.len() {
return None;
}
Some(if little_endian {
u32::from(read_u16_le(b, off))
} else {
u32::from(read_u16_be(b, off))
})
};
let u32 = |off: usize| -> Option<u32> {
if off + 4 > b.len() {
return None;
}
Some(if little_endian {
read_u32_le(b, off)
} else {
read_u32_be(b, off)
})
};
let ifd_offset = usize::try_from(u32(4)?).ok()?;
if ifd_offset + 2 > b.len() {
return None;
}
let entry_count = usize::try_from(u16(ifd_offset)?).ok()?;
let mut width: Option<u32> = None;
let mut height: Option<u32> = None;
for i in 0..entry_count {
let entry_off = ifd_offset + 2 + i * 12;
if entry_off + 12 > b.len() {
break;
}
let tag = u16(entry_off)?;
let _type = u16(entry_off + 2)?;
let value = u32(entry_off + 8)?;
match tag {
256 => width = Some(value),
257 => height = Some(value),
_ => {}
}
if width.is_some() && height.is_some() {
break;
}
}
width.zip(height)
}
fn svg_meta(b: &[u8]) -> ImageMeta {
let text = std::str::from_utf8(&b[..b.len().min(4096)]).unwrap_or("");
let dims = parse_svg_dimensions(text);
let label = extract_svg_label(text);
ImageMeta {
format: "SVG",
dimensions: dims,
label,
}
}
fn parse_svg_dimensions(svg: &str) -> Option<(u32, u32)> {
if let Some(vb) = extract_attr(svg, "viewBox") {
let nums: Vec<f64> = vb
.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
if nums.len() >= 4 {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let (w, h) = (nums[2].round() as u32, nums[3].round() as u32);
if w > 0 && h > 0 {
return Some((w, h));
}
}
}
let w_str = extract_attr(svg, "width")?;
let h_str = extract_attr(svg, "height")?;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let (w, h) = (parse_svg_unit(w_str)? as u32, parse_svg_unit(h_str)? as u32);
(w > 0 && h > 0).then_some((w, h))
}
fn parse_svg_unit(s: &str) -> Option<f64> {
let trimmed = s.trim();
let num_end = trimmed
.find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
.unwrap_or(trimmed.len());
trimmed[..num_end].parse().ok()
}
fn extract_svg_label(svg: &str) -> Option<String> {
for tag in &["title", "desc"] {
let open = format!("<{tag}");
let close = format!("</{tag}>");
if let Some(start) = svg.find(&open) {
if let Some(tag_end) = svg[start..].find('>') {
let content_start = start + tag_end + 1;
if let Some(content_end) = svg[content_start..].find(&close) {
let content = svg[content_start..content_start + content_end].trim();
if !content.is_empty() {
return Some(content.to_string());
}
}
}
}
}
None
}
fn isobmff_meta(b: &[u8]) -> ImageMeta {
let brand = std::str::from_utf8(b.get(8..12).unwrap_or(&[])).unwrap_or("");
let format = isobmff_format_name(brand);
let dims = scan_ispe_box(b);
ImageMeta {
format,
dimensions: dims,
label: None,
}
}
fn isobmff_format_name(brand: &str) -> &'static str {
match brand.trim() {
"avif" | "avis" => "AVIF",
"heic" | "heix" | "hevc" | "hevx" => "HEIC",
"mif1" | "msf1" => "HEIF",
_ => "AVIF/HEIF",
}
}
fn scan_ispe_box(b: &[u8]) -> Option<(u32, u32)> {
let mut i = 0usize;
while i + 8 <= b.len() {
let box_size = read_u32_be(b, i) as usize;
let box_type = &b[i + 4..i + 8];
if box_size < 8 {
break;
}
if box_type == b"ispe" && i + 20 <= b.len() {
let w = read_u32_be(b, i + 12);
let h = read_u32_be(b, i + 16);
if w > 0 && h > 0 {
return Some((w, h));
}
}
i += box_size;
}
None
}
#[allow(clippy::similar_names)]
fn extract_attr<'a>(text: &'a str, attr: &str) -> Option<&'a str> {
let needle_dq = format!("{attr}=\"");
let needle_sq = format!("{attr}='");
if let Some(start) = text.find(&needle_dq) {
let val_start = start + needle_dq.len();
let val_end = text[val_start..].find('"')?;
return Some(&text[val_start..val_start + val_end]);
}
if let Some(start) = text.find(&needle_sq) {
let val_start = start + needle_sq.len();
let val_end = text[val_start..].find('\'')?;
return Some(&text[val_start..val_start + val_end]);
}
None
}
fn mime_to_format_hint(content_type: &str) -> &'static str {
let mime = content_type.split(';').next().unwrap_or("").trim();
match mime {
"image/png" => "PNG",
"image/jpeg" | "image/jpg" => "JPEG",
"image/gif" => "GIF",
"image/webp" => "WebP",
"image/avif" => "AVIF",
"image/heic" | "image/heif" => "HEIC",
"image/bmp" => "BMP",
"image/x-icon" | "image/vnd.microsoft.icon" => "ICO",
"image/tiff" => "TIFF",
"image/svg+xml" => "SVG",
_ => "image",
}
}
fn read_u16_be(b: &[u8], off: usize) -> u16 {
u16::from_be_bytes([b[off], b[off + 1]])
}
fn read_u16_le(b: &[u8], off: usize) -> u16 {
u16::from_le_bytes([b[off], b[off + 1]])
}
fn read_u32_be(b: &[u8], off: usize) -> u32 {
u32::from_be_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
}
fn read_u32_le(b: &[u8], off: usize) -> u32 {
u32::from_le_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]])
}
fn read_u24_le(b: &[u8], off: usize) -> u32 {
u32::from(b[off]) | (u32::from(b[off + 1]) << 8) | (u32::from(b[off + 2]) << 16)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn png_magic_detection_recognises_valid_signature() {
let bytes = [0x89u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
assert!(is_png(&bytes));
}
#[test]
fn png_meta_extracts_dimensions_from_ihdr() {
let mut bytes = vec![
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, ];
bytes.extend_from_slice(&800u32.to_be_bytes());
bytes.extend_from_slice(&600u32.to_be_bytes());
bytes.extend_from_slice(&[8, 2, 0, 0, 0]); let meta = png_meta(&bytes);
assert_eq!(meta.dimensions, Some((800, 600)));
assert_eq!(meta.format, "PNG");
}
#[test]
fn describe_image_returns_correct_format_for_png_bytes() {
let mut bytes = vec![
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52,
];
bytes.extend_from_slice(&1u32.to_be_bytes()); bytes.extend_from_slice(&1u32.to_be_bytes()); bytes.extend_from_slice(&[8, 0, 0, 0, 0]);
let desc = describe_image(&bytes, "image/png");
assert!(desc.contains("[Image: PNG 1×1]"), "got: {desc}");
}
#[test]
fn jpeg_magic_detection_recognises_ffd8ff() {
let bytes = [0xFF, 0xD8, 0xFF, 0xE0];
assert!(is_jpeg(&bytes));
}
#[test]
fn jpeg_meta_extracts_dimensions_from_sof0() {
let mut bytes = vec![0xFF, 0xD8]; bytes.extend_from_slice(&[0xFF, 0xE0, 0x00, 0x10]);
bytes.extend_from_slice(&[
0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
]);
bytes.extend_from_slice(&[0xFF, 0xC0, 0x00, 0x11]);
bytes.push(8); bytes.extend_from_slice(&240u16.to_be_bytes()); bytes.extend_from_slice(&320u16.to_be_bytes()); bytes.extend_from_slice(&[3, 1, 0x11, 0, 2, 0x11, 1, 3, 0x11, 1]); let meta = jpeg_meta(&bytes);
assert_eq!(meta.dimensions, Some((320, 240)));
assert_eq!(meta.format, "JPEG");
}
#[test]
fn gif_meta_extracts_dimensions_from_logical_screen_descriptor() {
let mut bytes = b"GIF89a".to_vec();
bytes.extend_from_slice(&100u16.to_le_bytes()); bytes.extend_from_slice(&50u16.to_le_bytes()); let meta = gif_meta(&bytes);
assert_eq!(meta.dimensions, Some((100, 50)));
assert_eq!(meta.format, "GIF");
}
#[test]
fn is_gif_recognises_gif87a() {
assert!(is_gif(b"GIF87a\x00"));
}
#[test]
fn webp_vp8x_chunk_dimensions_parsed_correctly() {
let mut bytes = b"RIFF".to_vec();
bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(b"WEBP");
bytes.extend_from_slice(b"VP8X");
bytes.extend_from_slice(&10u32.to_le_bytes()); bytes.push(0x02); bytes.extend_from_slice(&[0; 3]); bytes.extend_from_slice(&[255, 0, 0]); bytes.extend_from_slice(&[127, 0, 0]); let meta = webp_meta(&bytes);
assert_eq!(meta.dimensions, Some((256, 128)));
assert_eq!(meta.format, "WebP");
}
#[test]
fn bmp_meta_extracts_dimensions_from_dib_header() {
let mut bytes = vec![0x42, 0x4D]; bytes.extend_from_slice(&54u32.to_le_bytes()); bytes.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); bytes.extend_from_slice(&54u32.to_le_bytes()); bytes.extend_from_slice(&40u32.to_le_bytes()); bytes.extend_from_slice(&64u32.to_le_bytes()); bytes.extend_from_slice(&48u32.to_le_bytes()); let meta = bmp_meta(&bytes);
assert_eq!(meta.dimensions, Some((64, 48)));
assert_eq!(meta.format, "BMP");
}
#[test]
fn ico_meta_extracts_32x32_from_first_entry() {
let bytes = [0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 32u8, 32u8];
let meta = ico_meta(&bytes);
assert_eq!(meta.dimensions, Some((32, 32)));
}
#[test]
fn ico_meta_zero_byte_means_256_pixels() {
let bytes = [0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00u8, 0x00u8];
let meta = ico_meta(&bytes);
assert_eq!(meta.dimensions, Some((256, 256)));
}
#[test]
fn svg_meta_extracts_viewbox_dimensions() {
let svg = r#"<svg viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg"></svg>"#;
let meta = svg_meta(svg.as_bytes());
assert_eq!(meta.dimensions, Some((800, 600)));
assert_eq!(meta.format, "SVG");
}
#[test]
fn svg_meta_extracts_title_as_label() {
let svg = r#"<svg viewBox="0 0 100 100"><title>Sales Chart 2024</title></svg>"#;
let meta = svg_meta(svg.as_bytes());
assert_eq!(meta.label, Some("Sales Chart 2024".to_string()));
}
#[test]
fn svg_meta_falls_back_to_width_height_when_no_viewbox() {
let svg = r#"<svg width="400" height="300" xmlns="http://www.w3.org/2000/svg"></svg>"#;
let meta = svg_meta(svg.as_bytes());
assert_eq!(meta.dimensions, Some((400, 300)));
}
#[test]
fn svg_meta_handles_px_unit_suffix() {
let svg = r#"<svg width="200px" height="150px"></svg>"#;
let meta = svg_meta(svg.as_bytes());
assert_eq!(meta.dimensions, Some((200, 150)));
}
#[test]
fn format_image_description_includes_format_and_dimensions() {
let meta = ImageMeta {
format: "PNG",
dimensions: Some((1920, 1080)),
label: None,
};
let desc = format_image_description(&meta, 12345);
assert_eq!(desc, "[Image: PNG 1920×1080]");
}
#[test]
fn format_image_description_uses_byte_count_when_no_dimensions() {
let meta = ImageMeta {
format: "JPEG",
dimensions: None,
label: None,
};
let desc = format_image_description(&meta, 8192);
assert_eq!(desc, "[Image: JPEG 8192 bytes]");
}
#[test]
fn format_image_description_appends_label_on_new_line() {
let meta = ImageMeta {
format: "SVG",
dimensions: Some((100, 100)),
label: Some("Pie chart".to_string()),
};
let desc = format_image_description(&meta, 0);
assert_eq!(desc, "[Image: SVG 100×100]\nPie chart");
}
#[test]
fn image_handler_supported_types_includes_common_formats() {
let handler = ImageHandler;
let types = handler.supported_types();
assert!(types.contains(&"image/png"));
assert!(types.contains(&"image/jpeg"));
assert!(types.contains(&"image/svg+xml"));
assert!(types.contains(&"image/webp"));
}
#[test]
fn image_handler_to_markdown_produces_image_marker_for_png() {
let mut bytes = vec![
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52,
];
bytes.extend_from_slice(&10u32.to_be_bytes()); bytes.extend_from_slice(&10u32.to_be_bytes()); bytes.extend_from_slice(&[8, 2, 0, 0, 0]);
let handler = ImageHandler;
let result = handler.to_markdown(&bytes, "image/png").unwrap();
assert!(
result.markdown.starts_with("[Image:"),
"got: {}",
result.markdown
);
assert!(result.markdown.contains("PNG"));
}
#[test]
fn mime_to_format_hint_maps_common_types() {
assert_eq!(mime_to_format_hint("image/png"), "PNG");
assert_eq!(mime_to_format_hint("image/jpeg"), "JPEG");
assert_eq!(mime_to_format_hint("image/gif"), "GIF");
assert_eq!(mime_to_format_hint("image/webp"), "WebP");
assert_eq!(mime_to_format_hint("image/svg+xml"), "SVG");
}
#[test]
fn mime_to_format_hint_strips_charset_parameter() {
let hint = mime_to_format_hint("image/png; charset=utf-8");
assert_eq!(hint, "PNG");
}
#[test]
fn content_router_dispatches_image_png_to_image_handler() {
let router = crate::content::ContentRouter::new();
let mut bytes = vec![
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52,
];
bytes.extend_from_slice(&32u32.to_be_bytes());
bytes.extend_from_slice(&32u32.to_be_bytes());
bytes.extend_from_slice(&[8, 2, 0, 0, 0]);
let result = router.convert(&bytes, "image/png").unwrap();
assert!(
result.markdown.starts_with("[Image:"),
"got: {}",
result.markdown
);
}
#[test]
fn content_router_dispatches_image_svg_xml_to_image_handler() {
let svg = br#"<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg"><title>Logo</title></svg>"#;
let router = crate::content::ContentRouter::new();
let result = router.convert(svg, "image/svg+xml").unwrap();
assert!(result.markdown.contains("SVG"), "got: {}", result.markdown);
assert!(result.markdown.contains("Logo"), "got: {}", result.markdown);
}
}