Files
galaxy/crates/galaxyui_core/src/clipboard_utils.rs
T

447 lines
14 KiB
Rust

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<String>,
text_content: &str,
) -> Option<String> {
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<String> {
// 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<String> {
// 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<String> {
// Helper function to extract quoted attribute value
let extract_quoted_value = |html: &str, attr_pattern: &str| -> Option<String> {
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::<Vec<_>>().join(" ");
}
fn decode_entity(entity: &str) -> Option<char> {
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::<u32>().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::<Vec<_>>().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<String>,
) -> Option<crate::clipboard::ImageData> {
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<String>,
text_content: &str,
) -> Option<Vec<crate::clipboard::ImageData>> {
// 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<String>,
) -> Option<crate::clipboard::ImageData> {
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<u8>,
filename: Option<String>,
) -> Option<crate::clipboard::ImageData> {
// 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::<image::Rgba<u8>, Vec<u8>>::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<String> {
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<galaxy_util::path::ShellFamily>,
) -> 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;