Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/clipboard_utils.rs
StratoSDK / crates / strato-ui-core / src / clipboard_utils.rs
1#[allow(unused_imports)]
2use crate::clipboard::{Clipboard, ClipboardContent};
3 
4#[cfg(any(target_os = "linux", target_os = "windows"))]
5use {arboard, image::ImageEncoder};
6 
7use itertools::Itertools;
8 
9/// Supported image file extensions for clipboard operations.
10pub const IMAGE_EXTENSIONS: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp"];
11 
12/// Preferred image MIME types for clipboard operations (in order of preference)
13pub 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"))]
23const MIN_IMAGE_HEADER_SIZE: usize = 8;
24 
25/// Check if a string has an image file extension.
26pub 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.
33fn 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.
44pub 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.).
55pub 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.
79pub 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.
95fn 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.
149pub 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"))]
274pub 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"))]
299pub 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"))]
330pub 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"))]
357pub 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 
416pub 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.
425pub 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"]
447mod tests;
448