use itertools::Itertools; #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))] use {arboard, image::ImageEncoder}; #[allow(unused_imports)] use crate::clipboard::{Clipboard, ClipboardContent}; /// Supported image file extensions for clipboard operations. pub const IMAGE_EXTENSIONS: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp"]; /// Preferred image MIME types for clipboard operations (in order of preference) pub const CLIPBOARD_IMAGE_MIME_TYPES: &[&str] = &[ "image/png", // Preferred: lossless, good compression "image/jpeg", // Good fallback: widely supported "image/jpg", // JPEG variant "image/gif", // Animated images "image/webp", // Modern format but less compatible ]; /// Minimum bytes needed for image format detection. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))] const MIN_IMAGE_HEADER_SIZE: usize = 8; /// Check if a string has an image file extension. pub fn has_image_extension(s: &str) -> bool { IMAGE_EXTENSIONS .iter() .any(|ext| s.to_lowercase().ends_with(ext)) } /// Extract filename from a file path, handling file:// URLs and path separators. fn extract_filename_from_path(path: &str) -> String { path.strip_prefix("file://") .unwrap_or(path) .split(['/', '\\']) .next_back() .unwrap_or(path) .to_string() } /// Extract filename from clipboard content (HTML or text). /// Tries HTML first, then falls back to text content. pub fn extract_filename_from_clipboard_content( html_content: &Option, text_content: &str, ) -> Option { html_content .as_ref() .and_then(|html| extract_filename_from_html(html)) .or_else(|| extract_filename_from_text(text_content)) } /// Extract filename from text content (file paths, URLs, etc.). pub fn extract_filename_from_text(text: &str) -> Option { // Early return for empty input if text.trim().is_empty() { return None; } // First, check if the entire text is a file path with an image extension let trimmed = text.trim(); if trimmed.contains('.') && has_image_extension(trimmed) { return Some(extract_filename_from_path(trimmed)); } // Look for file paths in the text for line in text.lines() { let line = line.trim(); if line.contains('.') && has_image_extension(line) { return Some(extract_filename_from_path(line)); } } None } /// Extract filename from HTML content. pub fn extract_filename_from_html(html: &str) -> Option { // Early return for empty HTML if html.trim().is_empty() { return None; } // First try to extract from HTML structure, then fall back to text extraction if let Some(filename) = extract_filename_from_html_tags(html) { return Some(filename); } // Fall back to treating HTML as plain text for file paths extract_filename_from_text(html) } /// Extract filename from HTML tags and attributes. fn extract_filename_from_html_tags(html: &str) -> Option { // Helper function to extract quoted attribute value let extract_quoted_value = |html: &str, attr_pattern: &str| -> Option { html.find(attr_pattern) .and_then(|start| { let content_start = start + attr_pattern.len(); html[content_start..].split('"').next() }) .filter(|s| !s.is_empty()) .map(|s| s.to_string()) }; // 1. Check src attribute in img tag (most common case) if let Some(src_content) = extract_quoted_value(html, "src=\"") { let filename = extract_filename_from_path(&src_content); if filename.contains('.') && has_image_extension(&filename) { return Some(filename); } } // 2. Check title attribute if let Some(title_content) = extract_quoted_value(html, "title=\"") { if title_content.contains('.') && has_image_extension(&title_content) { return Some(title_content); } } // 3. Check alt attribute if let Some(alt_content) = extract_quoted_value(html, "alt=\"") { if alt_content.contains('.') && has_image_extension(&alt_content) { return Some(alt_content); } } // 4. Look for any filename-like strings with image extensions in the entire HTML const TRIM_CHARS: &[char] = &['"', '\'', '<', '>', '(', ')', ',', ';']; for word in html.split_whitespace() { if word.contains('.') { let clean_word = word.trim_matches(TRIM_CHARS); if has_image_extension(clean_word) { let filename = extract_filename_from_path(clean_word); return Some(filename); } } } None } /// Best-effort conversion of HTML clipboard contents to plain text. /// /// This is intentionally lightweight (no external HTML parser dependency). It strips tags, /// decodes a small set of common entities, and collapses whitespace. pub fn strip_html_to_plain_text(html: &str) -> String { if html.trim().is_empty() { return String::new(); } // Fast path: if there are no obvious tag/entity markers, treat as plain text. if !html.contains('<') && !html.contains('&') { return html.split_whitespace().collect::>().join(" "); } fn decode_entity(entity: &str) -> Option { match entity { "nbsp" => Some(' '), "amp" => Some('&'), "lt" => Some('<'), "gt" => Some('>'), "quot" => Some('"'), "apos" => Some('\''), "#39" => Some('\''), _ if entity.starts_with("#x") || entity.starts_with("#X") => { u32::from_str_radix(&entity[2..], 16) .ok() .and_then(char::from_u32) } _ if entity.starts_with('#') => { entity[1..].parse::().ok().and_then(char::from_u32) } _ => None, } } let mut out = String::with_capacity(html.len()); let mut in_tag = false; let mut in_entity = false; let mut entity_buf = String::new(); let mut last_was_space = false; for ch in html.chars() { if in_tag { if ch == '>' { in_tag = false; // Treat tags as word boundaries. if !last_was_space { out.push(' '); last_was_space = true; } } continue; } if in_entity { if ch == ';' { let decoded = decode_entity(entity_buf.as_str()); if let Some(decoded) = decoded { if decoded.is_whitespace() { if !last_was_space { out.push(' '); last_was_space = true; } } else { out.push(decoded); last_was_space = false; } } else { // Unknown entity; keep it as-is (best effort). if !last_was_space { out.push(' '); } out.push('&'); out.push_str(entity_buf.as_str()); out.push(';'); out.push(' '); last_was_space = true; } entity_buf.clear(); in_entity = false; continue; } // Guard against extremely long/unterminated entities. if entity_buf.len() >= 24 { in_entity = false; entity_buf.clear(); if !last_was_space { out.push(' '); last_was_space = true; } continue; } entity_buf.push(ch); continue; } match ch { '<' => { in_tag = true; // Ensure words on either side of tags don't get glued together. if !last_was_space && !out.is_empty() { out.push(' '); last_was_space = true; } } '&' => { in_entity = true; entity_buf.clear(); } ch if ch.is_whitespace() => { if !last_was_space { out.push(' '); last_was_space = true; } } _ => { out.push(ch); last_was_space = false; } } } out.split_whitespace().collect::>().join(" ") } /// Process clipboard image data, preserving original format or converting to PNG. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))] pub fn process_clipboard_image( arboard_image: &arboard::ImageData, filename: Option, ) -> Option { let result = try_preserve_original_format(&arboard_image.bytes, filename.clone()).or_else(|| { convert_raw_bitmap_to_png( arboard_image.width, arboard_image.height, arboard_image.bytes.to_vec(), filename, ) }); if result.is_none() { log::warn!( "Failed to process clipboard image: format preservation and PNG conversion both failed" ); } result } /// Read image data from clipboard, checking for images before expensive filename extraction. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))] pub fn read_images_from_clipboard( clipboard: &mut arboard::Clipboard, html_content: &Option, text_content: &str, ) -> Option> { // First, quickly check if there are any images in the clipboard // This is a fast operation that avoids filename extraction overhead match clipboard.get().image() { Ok(arboard_image) => { // Images found! Now extract filename from clipboard content let filename = extract_filename_from_clipboard_content(html_content, text_content); // Process the image with the extracted filename match process_clipboard_image(&arboard_image, filename) { Some(image_data) => Some(vec![image_data]), None => { log::warn!("Failed to process clipboard image: format detection and conversion both failed"); None } } } Err(arboard::Error::ContentNotAvailable) => None, Err(err) => { log::warn!("Unable to read image from clipboard: {err:?}"); None } } } /// Try to preserve original image format using infer crate for detection. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))] pub fn try_preserve_original_format( bytes: &[u8], filename: Option, ) -> Option { if bytes.len() < MIN_IMAGE_HEADER_SIZE { return None; } // Use infer crate to detect the image format if let Some(kind) = infer::get(bytes) { // Check if it's a supported image format match kind.mime_type() { "image/png" | "image/jpeg" | "image/gif" | "image/webp" => { return Some(crate::clipboard::ImageData { data: bytes.to_vec(), mime_type: kind.mime_type().to_string(), filename, }); } _ => {} } } None } /// Converts RGBA bitmap data to PNG format, returns None on invalid dimensions/encoding. #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))] pub fn convert_raw_bitmap_to_png( width: usize, height: usize, bytes: Vec, filename: Option, ) -> Option { // Validate dimensions before processing let width_u32 = match width.try_into() { Ok(w) => w, Err(e) => { log::warn!("Invalid width for PNG conversion: {width} - {e}"); return None; } }; let height_u32 = match height.try_into() { Ok(h) => h, Err(e) => { log::warn!("Invalid height for PNG conversion: {height} - {e}"); return None; } }; // Create RGBA image buffer from raw data // Note: arboard should already provide data in RGBA format let img_buffer = image::ImageBuffer::, Vec>::from_raw(width_u32, height_u32, bytes)?; // Encode as PNG with optimized settings for speed let mut png_data = Vec::new(); let mut cursor = std::io::Cursor::new(&mut png_data); // Use fast compression settings to reduce encoding time let encoder = image::codecs::png::PngEncoder::new_with_quality( &mut cursor, image::codecs::png::CompressionType::Fast, image::codecs::png::FilterType::NoFilter, ); let encode_result = encoder.write_image( &img_buffer, width_u32, height_u32, image::ColorType::Rgba8.into(), ); match encode_result { Ok(_) => Some(crate::clipboard::ImageData { data: png_data, mime_type: "image/png".to_string(), filename, }), Err(err) => { log::warn!("PNG encoding failed: {err:?}"); None } } } pub fn get_image_filepaths_from_paths(paths: &[String]) -> Vec { paths .iter() .filter(|path| has_image_extension(path)) .cloned() .collect() } /// Create escaped file paths text string for insertion into terminal. pub fn escaped_paths_str( paths: &[String], shell_family: Option, ) -> String { // Handle regular file paths as text #[allow(unused_mut)] let mut input = paths .iter() .map(|path| match shell_family { Some(shell_family) => shell_family.escape(path.as_ref()), None => std::borrow::Cow::Borrowed(path.as_ref()), }) .join(" "); // Append a space in case of back-to-back drag-drops. input.push(' '); input } #[cfg(test)] #[path = "clipboard_utils_tests.rs"] mod tests;