StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | #[allow(unused_imports)] |
| 2 | use crate::clipboard::{Clipboard, ClipboardContent}; |
| 3 | |
| 4 | #[cfg(any(target_os = "linux", target_os = "windows"))] |
| 5 | use {arboard, image::ImageEncoder}; |
| 6 | |
| 7 | use itertools::Itertools; |
| 8 | |
| 9 | /// Supported image file extensions for clipboard operations. |
| 10 | pub const IMAGE_EXTENSIONS: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp"]; |
| 11 | |
| 12 | /// Preferred image MIME types for clipboard operations (in order of preference) |
| 13 | pub const CLIPBOARD_IMAGE_MIME_TYPES: &[&str] = &[ |
| 14 | "image/png", // Preferred: lossless, good compression |
| 15 | "image/jpeg", // Good fallback: widely supported |
| 16 | "image/jpg", // JPEG variant |
| 17 | "image/gif", // Animated images |
| 18 | "image/webp", // Modern format but less compatible |
| 19 | ]; |
| 20 | |
| 21 | /// Minimum bytes needed for image format detection. |
| 22 | #[cfg(any(target_os = "linux", target_os = "windows"))] |
| 23 | const MIN_IMAGE_HEADER_SIZE: usize = 8; |
| 24 | |
| 25 | /// Check if a string has an image file extension. |
| 26 | pub fn has_image_extension(s: &str) -> bool { |
| 27 | IMAGE_EXTENSIONS |
| 28 | .iter() |
| 29 | .any(|ext| s.to_lowercase().ends_with(ext)) |
| 30 | } |
| 31 | |
| 32 | /// Extract filename from a file path, handling file:// URLs and path separators. |
| 33 | fn extract_filename_from_path(path: &str) -> String { |
| 34 | path.strip_prefix("file://") |
| 35 | .unwrap_or(path) |
| 36 | .split(['/', '\\']) |
| 37 | .next_back() |
| 38 | .unwrap_or(path) |
| 39 | .to_string() |
| 40 | } |
| 41 | |
| 42 | /// Extract filename from clipboard content (HTML or text). |
| 43 | /// Tries HTML first, then falls back to text content. |
| 44 | pub fn extract_filename_from_clipboard_content( |
| 45 | html_content: &Option<String>, |
| 46 | text_content: &str, |
| 47 | ) -> Option<String> { |
| 48 | html_content |
| 49 | .as_ref() |
| 50 | .and_then(|html| extract_filename_from_html(html)) |
| 51 | .or_else(|| extract_filename_from_text(text_content)) |
| 52 | } |
| 53 | |
| 54 | /// Extract filename from text content (file paths, URLs, etc.). |
| 55 | pub fn extract_filename_from_text(text: &str) -> Option<String> { |
| 56 | // Early return for empty input |
| 57 | if text.trim().is_empty() { |
| 58 | return None; |
| 59 | } |
| 60 | |
| 61 | // First, check if the entire text is a file path with an image extension |
| 62 | let trimmed = text.trim(); |
| 63 | if trimmed.contains('.') && has_image_extension(trimmed) { |
| 64 | return Some(extract_filename_from_path(trimmed)); |
| 65 | } |
| 66 | |
| 67 | // Look for file paths in the text |
| 68 | for line in text.lines() { |
| 69 | let line = line.trim(); |
| 70 | if line.contains('.') && has_image_extension(line) { |
| 71 | return Some(extract_filename_from_path(line)); |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | None |
| 76 | } |
| 77 | |
| 78 | /// Extract filename from HTML content. |
| 79 | pub fn extract_filename_from_html(html: &str) -> Option<String> { |
| 80 | // Early return for empty HTML |
| 81 | if html.trim().is_empty() { |
| 82 | return None; |
| 83 | } |
| 84 | |
| 85 | // First try to extract from HTML structure, then fall back to text extraction |
| 86 | if let Some(filename) = extract_filename_from_html_tags(html) { |
| 87 | return Some(filename); |
| 88 | } |
| 89 | |
| 90 | // Fall back to treating HTML as plain text for file paths |
| 91 | extract_filename_from_text(html) |
| 92 | } |
| 93 | |
| 94 | /// Extract filename from HTML tags and attributes. |
| 95 | fn extract_filename_from_html_tags(html: &str) -> Option<String> { |
| 96 | // Helper function to extract quoted attribute value |
| 97 | let extract_quoted_value = |html: &str, attr_pattern: &str| -> Option<String> { |
| 98 | html.find(attr_pattern) |
| 99 | .and_then(|start| { |
| 100 | let content_start = start + attr_pattern.len(); |
| 101 | html[content_start..].split('"').next() |
| 102 | }) |
| 103 | .filter(|s| !s.is_empty()) |
| 104 | .map(|s| s.to_string()) |
| 105 | }; |
| 106 | |
| 107 | // 1. Check src attribute in img tag (most common case) |
| 108 | if let Some(src_content) = extract_quoted_value(html, "src=\"") { |
| 109 | let filename = extract_filename_from_path(&src_content); |
| 110 | if filename.contains('.') && has_image_extension(&filename) { |
| 111 | return Some(filename); |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // 2. Check title attribute |
| 116 | if let Some(title_content) = extract_quoted_value(html, "title=\"") { |
| 117 | if title_content.contains('.') && has_image_extension(&title_content) { |
| 118 | return Some(title_content); |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // 3. Check alt attribute |
| 123 | if let Some(alt_content) = extract_quoted_value(html, "alt=\"") { |
| 124 | if alt_content.contains('.') && has_image_extension(&alt_content) { |
| 125 | return Some(alt_content); |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | // 4. Look for any filename-like strings with image extensions in the entire HTML |
| 130 | const TRIM_CHARS: &[char] = &['"', '\'', '<', '>', '(', ')', ',', ';']; |
| 131 | |
| 132 | for word in html.split_whitespace() { |
| 133 | if word.contains('.') { |
| 134 | let clean_word = word.trim_matches(TRIM_CHARS); |
| 135 | if has_image_extension(clean_word) { |
| 136 | let filename = extract_filename_from_path(clean_word); |
| 137 | return Some(filename); |
| 138 | } |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | None |
| 143 | } |
| 144 | |
| 145 | /// Best-effort conversion of HTML clipboard contents to plain text. |
| 146 | /// |
| 147 | /// This is intentionally lightweight (no external HTML parser dependency). It strips tags, |
| 148 | /// decodes a small set of common entities, and collapses whitespace. |
| 149 | pub fn strip_html_to_plain_text(html: &str) -> String { |
| 150 | if html.trim().is_empty() { |
| 151 | return String::new(); |
| 152 | } |
| 153 | |
| 154 | // Fast path: if there are no obvious tag/entity markers, treat as plain text. |
| 155 | if !html.contains('<') && !html.contains('&') { |
| 156 | return html.split_whitespace().collect::<Vec<_>>().join(" "); |
| 157 | } |
| 158 | |
| 159 | fn decode_entity(entity: &str) -> Option<char> { |
| 160 | match entity { |
| 161 | "nbsp" => Some(' '), |
| 162 | "amp" => Some('&'), |
| 163 | "lt" => Some('<'), |
| 164 | "gt" => Some('>'), |
| 165 | "quot" => Some('"'), |
| 166 | "apos" => Some('\''), |
| 167 | "#39" => Some('\''), |
| 168 | _ if entity.starts_with("#x") || entity.starts_with("#X") => { |
| 169 | u32::from_str_radix(&entity[2..], 16) |
| 170 | .ok() |
| 171 | .and_then(char::from_u32) |
| 172 | } |
| 173 | _ if entity.starts_with('#') => { |
| 174 | entity[1..].parse::<u32>().ok().and_then(char::from_u32) |
| 175 | } |
| 176 | _ => None, |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | let mut out = String::with_capacity(html.len()); |
| 181 | let mut in_tag = false; |
| 182 | let mut in_entity = false; |
| 183 | let mut entity_buf = String::new(); |
| 184 | let mut last_was_space = false; |
| 185 | |
| 186 | for ch in html.chars() { |
| 187 | if in_tag { |
| 188 | if ch == '>' { |
| 189 | in_tag = false; |
| 190 | // Treat tags as word boundaries. |
| 191 | if !last_was_space { |
| 192 | out.push(' '); |
| 193 | last_was_space = true; |
| 194 | } |
| 195 | } |
| 196 | continue; |
| 197 | } |
| 198 | |
| 199 | if in_entity { |
| 200 | if ch == ';' { |
| 201 | let decoded = decode_entity(entity_buf.as_str()); |
| 202 | if let Some(decoded) = decoded { |
| 203 | if decoded.is_whitespace() { |
| 204 | if !last_was_space { |
| 205 | out.push(' '); |
| 206 | last_was_space = true; |
| 207 | } |
| 208 | } else { |
| 209 | out.push(decoded); |
| 210 | last_was_space = false; |
| 211 | } |
| 212 | } else { |
| 213 | // Unknown entity; keep it as-is (best effort). |
| 214 | if !last_was_space { |
| 215 | out.push(' '); |
| 216 | } |
| 217 | out.push('&'); |
| 218 | out.push_str(entity_buf.as_str()); |
| 219 | out.push(';'); |
| 220 | out.push(' '); |
| 221 | last_was_space = true; |
| 222 | } |
| 223 | entity_buf.clear(); |
| 224 | in_entity = false; |
| 225 | continue; |
| 226 | } |
| 227 | |
| 228 | // Guard against extremely long/unterminated entities. |
| 229 | if entity_buf.len() >= 24 { |
| 230 | in_entity = false; |
| 231 | entity_buf.clear(); |
| 232 | if !last_was_space { |
| 233 | out.push(' '); |
| 234 | last_was_space = true; |
| 235 | } |
| 236 | continue; |
| 237 | } |
| 238 | |
| 239 | entity_buf.push(ch); |
| 240 | continue; |
| 241 | } |
| 242 | |
| 243 | match ch { |
| 244 | '<' => { |
| 245 | in_tag = true; |
| 246 | // Ensure words on either side of tags don't get glued together. |
| 247 | if !last_was_space && !out.is_empty() { |
| 248 | out.push(' '); |
| 249 | last_was_space = true; |
| 250 | } |
| 251 | } |
| 252 | '&' => { |
| 253 | in_entity = true; |
| 254 | entity_buf.clear(); |
| 255 | } |
| 256 | ch if ch.is_whitespace() => { |
| 257 | if !last_was_space { |
| 258 | out.push(' '); |
| 259 | last_was_space = true; |
| 260 | } |
| 261 | } |
| 262 | _ => { |
| 263 | out.push(ch); |
| 264 | last_was_space = false; |
| 265 | } |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | out.split_whitespace().collect::<Vec<_>>().join(" ") |
| 270 | } |
| 271 | |
| 272 | /// Process clipboard image data, preserving original format or converting to PNG. |
| 273 | #[cfg(any(target_os = "linux", target_os = "windows"))] |
| 274 | pub fn process_clipboard_image( |
| 275 | arboard_image: &arboard::ImageData, |
| 276 | filename: Option<String>, |
| 277 | ) -> Option<crate::clipboard::ImageData> { |
| 278 | let result = |
| 279 | try_preserve_original_format(&arboard_image.bytes, filename.clone()).or_else(|| { |
| 280 | convert_raw_bitmap_to_png( |
| 281 | arboard_image.width, |
| 282 | arboard_image.height, |
| 283 | arboard_image.bytes.to_vec(), |
| 284 | filename, |
| 285 | ) |
| 286 | }); |
| 287 | |
| 288 | if result.is_none() { |
| 289 | log::warn!( |
| 290 | "Failed to process clipboard image: format preservation and PNG conversion both failed" |
| 291 | ); |
| 292 | } |
| 293 | |
| 294 | result |
| 295 | } |
| 296 | |
| 297 | /// Read image data from clipboard, checking for images before expensive filename extraction. |
| 298 | #[cfg(any(target_os = "linux", target_os = "windows"))] |
| 299 | pub fn read_images_from_clipboard( |
| 300 | clipboard: &mut arboard::Clipboard, |
| 301 | html_content: &Option<String>, |
| 302 | text_content: &str, |
| 303 | ) -> Option<Vec<crate::clipboard::ImageData>> { |
| 304 | // First, quickly check if there are any images in the clipboard |
| 305 | // This is a fast operation that avoids filename extraction overhead |
| 306 | match clipboard.get().image() { |
| 307 | Ok(arboard_image) => { |
| 308 | // Images found! Now extract filename from clipboard content |
| 309 | let filename = extract_filename_from_clipboard_content(html_content, text_content); |
| 310 | |
| 311 | // Process the image with the extracted filename |
| 312 | match process_clipboard_image(&arboard_image, filename) { |
| 313 | Some(image_data) => Some(vec![image_data]), |
| 314 | None => { |
| 315 | log::warn!("Failed to process clipboard image: format detection and conversion both failed"); |
| 316 | None |
| 317 | } |
| 318 | } |
| 319 | } |
| 320 | Err(arboard::Error::ContentNotAvailable) => None, |
| 321 | Err(err) => { |
| 322 | log::warn!("Unable to read image from clipboard: {err:?}"); |
| 323 | None |
| 324 | } |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | /// Try to preserve original image format using infer crate for detection. |
| 329 | #[cfg(any(target_os = "linux", target_os = "windows"))] |
| 330 | pub fn try_preserve_original_format( |
| 331 | bytes: &[u8], |
| 332 | filename: Option<String>, |
| 333 | ) -> Option<crate::clipboard::ImageData> { |
| 334 | if bytes.len() < MIN_IMAGE_HEADER_SIZE { |
| 335 | return None; |
| 336 | } |
| 337 | |
| 338 | // Use infer crate to detect the image format |
| 339 | if let Some(kind) = infer::get(bytes) { |
| 340 | // Check if it's a supported image format |
| 341 | match kind.mime_type() { |
| 342 | "image/png" | "image/jpeg" | "image/gif" | "image/webp" => { |
| 343 | return Some(crate::clipboard::ImageData { |
| 344 | data: bytes.to_vec(), |
| 345 | mime_type: kind.mime_type().to_string(), |
| 346 | filename, |
| 347 | }); |
| 348 | } |
| 349 | _ => {} |
| 350 | } |
| 351 | } |
| 352 | None |
| 353 | } |
| 354 | |
| 355 | /// Converts RGBA bitmap data to PNG format, returns None on invalid dimensions/encoding. |
| 356 | #[cfg(any(target_os = "linux", target_os = "windows"))] |
| 357 | pub fn convert_raw_bitmap_to_png( |
| 358 | width: usize, |
| 359 | height: usize, |
| 360 | bytes: Vec<u8>, |
| 361 | filename: Option<String>, |
| 362 | ) -> Option<crate::clipboard::ImageData> { |
| 363 | // Validate dimensions before processing |
| 364 | let width_u32 = match width.try_into() { |
| 365 | Ok(w) => w, |
| 366 | Err(e) => { |
| 367 | log::warn!("Invalid width for PNG conversion: {width} - {e}"); |
| 368 | return None; |
| 369 | } |
| 370 | }; |
| 371 | |
| 372 | let height_u32 = match height.try_into() { |
| 373 | Ok(h) => h, |
| 374 | Err(e) => { |
| 375 | log::warn!("Invalid height for PNG conversion: {height} - {e}"); |
| 376 | return None; |
| 377 | } |
| 378 | }; |
| 379 | |
| 380 | // Create RGBA image buffer from raw data |
| 381 | // Note: arboard should already provide data in RGBA format |
| 382 | let img_buffer = |
| 383 | image::ImageBuffer::<image::Rgba<u8>, Vec<u8>>::from_raw(width_u32, height_u32, bytes)?; |
| 384 | |
| 385 | // Encode as PNG with optimized settings for speed |
| 386 | let mut png_data = Vec::new(); |
| 387 | let mut cursor = std::io::Cursor::new(&mut png_data); |
| 388 | |
| 389 | // Use fast compression settings to reduce encoding time |
| 390 | let encoder = image::codecs::png::PngEncoder::new_with_quality( |
| 391 | &mut cursor, |
| 392 | image::codecs::png::CompressionType::Fast, |
| 393 | image::codecs::png::FilterType::NoFilter, |
| 394 | ); |
| 395 | |
| 396 | let encode_result = encoder.write_image( |
| 397 | &img_buffer, |
| 398 | width_u32, |
| 399 | height_u32, |
| 400 | image::ColorType::Rgba8.into(), |
| 401 | ); |
| 402 | |
| 403 | match encode_result { |
| 404 | Ok(_) => Some(crate::clipboard::ImageData { |
| 405 | data: png_data, |
| 406 | mime_type: "image/png".to_string(), |
| 407 | filename, |
| 408 | }), |
| 409 | Err(err) => { |
| 410 | log::warn!("PNG encoding failed: {err:?}"); |
| 411 | None |
| 412 | } |
| 413 | } |
| 414 | } |
| 415 | |
| 416 | pub fn get_image_filepaths_from_paths(paths: &[String]) -> Vec<String> { |
| 417 | paths |
| 418 | .iter() |
| 419 | .filter(|path| has_image_extension(path)) |
| 420 | .cloned() |
| 421 | .collect() |
| 422 | } |
| 423 | |
| 424 | /// Create escaped file paths text string for insertion into terminal. |
| 425 | pub fn escaped_paths_str( |
| 426 | paths: &[String], |
| 427 | shell_family: Option<crate::platform::ShellFamily>, |
| 428 | ) -> String { |
| 429 | // Handle regular file paths as text |
| 430 | #[allow(unused_mut)] |
| 431 | let mut input = paths |
| 432 | .iter() |
| 433 | .map(|path| match shell_family { |
| 434 | Some(shell_family) => shell_family.escape(path.as_ref()), |
| 435 | None => std::borrow::Cow::Borrowed(path.as_ref()), |
| 436 | }) |
| 437 | .join(" "); |
| 438 | |
| 439 | // Append a space in case of back-to-back drag-drops. |
| 440 | input.push(' '); |
| 441 | |
| 442 | input |
| 443 | } |
| 444 | |
| 445 | #[cfg(test)] |
| 446 | #[path = "clipboard_utils_tests.rs"] |
| 447 | mod tests; |
| 448 |