feat: implement crop functionality with self-contained widget

- Inspired by cosmic-viewer's crop implementation (https://codeberg.org/bhh32/cosmic-viewer)
- Add crop support for all document types (Raster, Vector, Portable)
This commit is contained in:
wfx 2026-01-22 20:40:36 +01:00
parent 9399a008c4
commit 3cf99ad19d
20 changed files with 1042 additions and 103 deletions

View file

@ -42,14 +42,14 @@ fn cache_key(file_path: &Path, page: usize) -> Option<String> {
hasher.update(page.to_le_bytes());
let hash = hasher.finalize();
Some(format!("{:x}", hash))
Some(format!("{hash:x}"))
}
/// Get the full path for a cached thumbnail.
fn thumbnail_path(file_path: &Path, page: usize) -> Option<PathBuf> {
let dir = cache_dir()?;
let key = cache_key(file_path, page)?;
Some(dir.join(format!("{}.{}", key, THUMBNAIL_EXT)))
Some(dir.join(format!("{key}.{THUMBNAIL_EXT}")))
}
/// Load a thumbnail from disk cache.
@ -81,7 +81,7 @@ pub fn load_thumbnail(file_path: &Path, page: usize) -> Option<ImageHandle> {
pub fn save_thumbnail(file_path: &Path, page: usize, image: &DynamicImage) -> Option<()> {
let dir = ensure_cache_dir()?;
let key = cache_key(file_path, page)?;
let cache_path = dir.join(format!("{}.{}", key, THUMBNAIL_EXT));
let cache_path = dir.join(format!("{key}.{THUMBNAIL_EXT}"));
log::debug!(
"Saving thumbnail to cache: file={}, page={}, path={}",
@ -98,7 +98,7 @@ pub fn save_thumbnail(file_path: &Path, page: usize, image: &DynamicImage) -> Op
image::ImageFormat::Png,
);
match res {
Ok(_) => {
Ok(()) => {
log::debug!(
"Thumbnail cached successfully: file={} page={}",
file_path.display(),
@ -121,17 +121,16 @@ pub fn save_thumbnail(file_path: &Path, page: usize, image: &DynamicImage) -> Op
/// Check if a thumbnail exists in cache.
#[allow(dead_code)]
pub fn has_thumbnail(file_path: &Path, page: usize) -> bool {
thumbnail_path(file_path, page)
.map(|p| p.exists())
.unwrap_or(false)
thumbnail_path(file_path, page).is_some_and(|p| p.exists())
}
/// Clear all cached thumbnails.
#[allow(dead_code)]
pub fn clear_cache() -> std::io::Result<()> {
if let Some(dir) = cache_dir()
&& dir.exists() {
fs::remove_dir_all(&dir)?;
}
&& dir.exists()
{
fs::remove_dir_all(&dir)?;
}
Ok(())
}

View file

@ -21,7 +21,7 @@ use crate::app::model::{AppModel, ViewMode};
/// based on enabled codecs (e.g. default-formats).
pub fn open_document(path: &Path) -> anyhow::Result<DocumentContent> {
let kind = DocumentKind::from_path(path)
.ok_or_else(|| anyhow!("Unsupported document type: {:?}", path))?;
.ok_or_else(|| anyhow!("Unsupported document type: {}", path.display()))?;
let content = match kind {
DocumentKind::Raster => {
@ -46,11 +46,11 @@ pub fn open_document(path: &Path) -> anyhow::Result<DocumentContent> {
/// If `path` is a directory, this will collect supported documents inside it,
/// open the first one, and initialize navigation state. If it is a file, the
/// file is opened directly and the surrounding folder is scanned.
pub fn open_initial_path(model: &mut AppModel, path: PathBuf) {
pub fn open_initial_path(model: &mut AppModel, path: &PathBuf) {
if path.is_dir() {
open_from_directory(model, &path);
open_from_directory(model, path);
} else {
open_single_file(model, &path);
open_single_file(model, path);
}
}
@ -80,9 +80,10 @@ pub fn open_single_file(model: &mut AppModel, path: &Path) {
// Refresh folder listing based on parent directory.
if model.document.is_some()
&& let Some(parent) = path.parent() {
refresh_folder_entries(model, parent, path);
}
&& let Some(parent) = path.parent()
{
refresh_folder_entries(model, parent, path);
}
}
/// Load a document into the model, resetting view state.
@ -200,3 +201,51 @@ pub fn file_size(path: &Path) -> u64 {
pub fn read_file_bytes(path: &Path) -> Option<Vec<u8>> {
fs::read(path).ok()
}
// ---------------------------------------------------------------------------
// Crop operations
// ---------------------------------------------------------------------------
/// Save a cropped version of the document with coordinates in filename.
///
/// Format: "original_NAME_X_Y.EXT"
/// Example: "image.png" → "image_100_200.png"
pub fn save_crop_as(
doc: &DocumentContent,
original_path: &Path,
x: u32,
y: u32,
width: u32,
height: u32,
) -> Result<PathBuf, String> {
let stem = original_path
.file_stem()
.ok_or_else(|| "Invalid path".to_string())?
.to_string_lossy();
let ext = original_path
.extension()
.ok_or_else(|| "No extension".to_string())?
.to_string_lossy();
let new_filename = format!("{stem}_{x}_{y}");
let new_path = original_path
.with_file_name(&new_filename)
.with_extension(ext.as_ref());
match doc {
DocumentContent::Raster(raster_doc) => {
let cropped_image = raster_doc
.crop_to_image(x, y, width, height)
.map_err(|e| e.to_string())?;
cropped_image.save(&new_path).map_err(|e| e.to_string())?;
}
DocumentContent::Vector(_) => {
return Err("Crop not supported for vector documents".to_string());
}
DocumentContent::Portable(_) => {
return Err("Crop not supported for PDF documents".to_string());
}
}
Ok(new_path)
}

View file

@ -38,14 +38,19 @@ impl BasicMeta {
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
#[allow(clippy::cast_precision_loss)]
if self.file_size >= GB {
format!("{:.2} GB", self.file_size as f64 / GB as f64)
let size_gb = self.file_size as f64 / GB as f64;
format!("{size_gb:.2} GB")
} else if self.file_size >= MB {
format!("{:.2} MB", self.file_size as f64 / MB as f64)
let size_mb = self.file_size as f64 / MB as f64;
format!("{size_mb:.2} MB")
} else if self.file_size >= KB {
format!("{:.1} KB", self.file_size as f64 / KB as f64)
let size_kb = self.file_size as f64 / KB as f64;
format!("{size_kb:.1} KB")
} else {
format!("{} B", self.file_size)
let size = self.file_size;
format!("{size} B")
}
}
@ -77,7 +82,7 @@ impl ExifMeta {
if model.starts_with(make) {
Some(model.clone())
} else {
Some(format!("{} {}", make, model))
Some(format!("{make} {model}"))
}
}
(Some(make), None) => Some(make.clone()),
@ -89,7 +94,7 @@ impl ExifMeta {
/// Format GPS coordinates for display.
pub fn gps_display(&self) -> Option<String> {
match (self.gps_latitude, self.gps_longitude) {
(Some(lat), Some(lon)) => Some(format!("{:.5}, {:.5}", lat, lon)),
(Some(lat), Some(lon)) => Some(format!("{lat:.5}, {lon:.5}")),
_ => None,
}
}
@ -165,9 +170,10 @@ fn extract_exif_from_bytes(data: &[u8]) -> Option<ExifMeta> {
}
if let Some(field) = exif.get_field(Tag::PhotographicSensitivity, In::PRIMARY)
&& let Value::Short(ref vals) = field.value
&& let Some(&iso) = vals.first() {
meta.iso = Some(iso as u32);
}
&& let Some(&iso) = vals.first()
{
meta.iso = Some(u32::from(iso));
}
if let Some(field) = exif.get_field(Tag::FocalLength, In::PRIMARY) {
meta.focal_length = Some(field.display_value().to_string());
}
@ -210,7 +216,10 @@ fn extract_gps_coord(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option<
/// Determine color type string from DynamicImage.
fn color_type_string(img: &DynamicImage) -> String {
use image::DynamicImage::*;
use image::DynamicImage::{
ImageLuma8, ImageLumaA8, ImageRgb8, ImageRgba8, ImageLuma16, ImageLumaA16, ImageRgb16,
ImageRgba16, ImageRgb32F, ImageRgba32F,
};
match img {
ImageLuma8(_) => "Grayscale 8-bit".to_string(),
ImageLumaA8(_) => "Grayscale+Alpha 8-bit".to_string(),
@ -230,8 +239,7 @@ fn color_type_string(img: &DynamicImage) -> String {
fn format_from_extension(path: &Path) -> String {
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_uppercase())
.unwrap_or_else(|| "Unknown".to_string())
.map_or_else(|| "Unknown".to_string(), str::to_uppercase)
}
// ---------------------------------------------------------------------------
@ -259,7 +267,7 @@ pub fn build_vector_meta(path: &Path, width: u32, height: u32) -> DocumentMeta {
/// Build metadata for a portable document.
pub fn build_portable_meta(path: &Path, width: u32, height: u32, page_count: u32) -> DocumentMeta {
let format = format!("PDF ({} pages)", page_count);
let format = format!("PDF ({page_count} pages)");
let basic = extract_basic_meta(path, width, height, &format, "Rendered".to_string());
DocumentMeta { basic, exif: None }

View file

@ -6,18 +6,26 @@
pub mod cache;
pub mod file;
pub mod meta;
pub mod portable;
pub mod raster;
pub mod utils;
#[cfg(feature = "portable")]
pub mod portable;
#[cfg(feature = "image")]
pub mod raster;
#[cfg(feature = "vector")]
pub mod vector;
use cosmic::iced_renderer::graphics::image::image_rs::ImageFormat as CosmicImageFormat;
#[cfg(feature = "image")]
use image::GenericImageView;
use std::fmt;
use std::path::Path;
#[cfg(feature = "portable")]
use self::portable::PortableDocument;
#[cfg(feature = "image")]
use self::raster::RasterDocument;
#[cfg(feature = "vector")]
use self::vector::VectorDocument;
// ============================================================================
@ -96,8 +104,6 @@ pub struct TransformState {
pub flip_v: bool,
}
/// Output of a render operation.
///
/// Used as return type for the `Renderable::render()` trait method.
@ -360,6 +366,18 @@ impl DocumentContent {
self.flip(FlipDirection::Vertical);
}
/// Crop the document to the specified rectangle.
///
/// Only supported for raster images. Returns an error for vector/PDF documents.
/// Coordinates are in pixels relative to current image dimensions.
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> DocResult<()> {
match self {
Self::Raster(doc) => doc.crop(x, y, width, height),
Self::Vector(_) => Err(anyhow::anyhow!("Crop not supported for vector documents")),
Self::Portable(_) => Err(anyhow::anyhow!("Crop not supported for PDF documents")),
}
}
/// Get document kind.
///
/// Reserved for future use (format-specific optimizations, statistics).
@ -446,7 +464,9 @@ impl DocumentContent {
/// Currently unused - thumbnails are generated incrementally via `generate_thumbnail_page()`.
#[allow(dead_code)]
pub fn generate_thumbnails(&mut self) {
if let Self::Portable(doc) = self { doc.generate_all_thumbnails() }
if let Self::Portable(doc) = self {
doc.generate_all_thumbnails()
}
}
/// Get current image handle for display.

View file

@ -40,7 +40,7 @@ impl PortableDocument {
/// Open a PDF document and render the first page.
pub fn open(path: &Path) -> anyhow::Result<Self> {
let document = PopplerDocument::new_from_file(path, None)
.map_err(|e| anyhow::anyhow!("Failed to parse PDF: {}", e))?;
.map_err(|e| anyhow::anyhow!("Failed to parse PDF: {e}"))?;
let num_pages = document.get_n_pages();
if num_pages == 0 {
@ -107,14 +107,13 @@ impl PortableDocument {
return handle;
}
match Self::render_page_at_scale(&self.document, page, Rotation::None, PDF_THUMBNAIL_SIZE)
{
match Self::render_page_at_scale(&self.document, page, Rotation::None, PDF_THUMBNAIL_SIZE) {
Ok(img) => {
let _ = cache::save_thumbnail(&self.source_path, page, &img);
super::create_image_handle_from_image(&img)
}
Err(e) => {
log::warn!("Failed to generate thumbnail for page {}: {}", page, e);
log::warn!("Failed to generate thumbnail for page {page}: {e}");
ImageHandle::from_rgba(1, 1, vec![0, 0, 0, 0])
}
}
@ -138,7 +137,7 @@ impl PortableDocument {
) -> anyhow::Result<DynamicImage> {
let page = document
.get_page(page_index)
.ok_or_else(|| anyhow::anyhow!("Failed to get page {}", page_index))?;
.ok_or_else(|| anyhow::anyhow!("Failed to get page {page_index}"))?;
let (page_width, page_height) = page.get_size();
let rotation_degrees = rotation.to_degrees();
@ -155,10 +154,10 @@ impl PortableDocument {
let scaled_height = (height * scale) as i32;
let surface = ImageSurface::create(Format::ARgb32, scaled_width, scaled_height)
.map_err(|e| anyhow::anyhow!("Failed to create Cairo surface: {}", e))?;
.map_err(|e| anyhow::anyhow!("Failed to create Cairo surface: {e}"))?;
let context = Context::new(&surface)
.map_err(|e| anyhow::anyhow!("Failed to create Cairo context: {}", e))?;
.map_err(|e| anyhow::anyhow!("Failed to create Cairo context: {e}"))?;
// Fill with white background.
context.set_source_rgb(1.0, 1.0, 1.0);
@ -182,13 +181,13 @@ impl PortableDocument {
let mut png_data: Vec<u8> = Vec::new();
surface
.write_to_png(&mut png_data)
.map_err(|e| anyhow::anyhow!("Failed to write PNG: {}", e))?;
.map_err(|e| anyhow::anyhow!("Failed to write PNG: {e}"))?;
let image = ImageReader::new(Cursor::new(png_data))
.with_guessed_format()
.map_err(|e| anyhow::anyhow!("Failed to read PNG format: {}", e))?
.map_err(|e| anyhow::anyhow!("Failed to read PNG format: {e}"))?
.decode()
.map_err(|e| anyhow::anyhow!("Failed to decode PNG: {}", e))?;
.map_err(|e| anyhow::anyhow!("Failed to decode PNG: {e}"))?;
Ok(image)
}
@ -208,7 +207,7 @@ impl PortableDocument {
self.refresh_handle();
}
Err(e) => {
log::error!("Failed to render PDF page: {}", e);
log::error!("Failed to render PDF page: {e}");
}
}
}

View file

@ -62,6 +62,54 @@ impl RasterDocument {
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
super::meta::build_raster_meta(path, &self.document, self.native_width, self.native_height)
}
/// Crop the image to the specified rectangle.
///
/// Coordinates are in pixels relative to the current image dimensions.
/// Returns an error if the rectangle is out of bounds.
pub fn crop(&mut self, x: u32, y: u32, width: u32, height: u32) -> DocResult<()> {
let (img_width, img_height) = self.document.dimensions();
if x + width > img_width || y + height > img_height {
return Err(anyhow::anyhow!(
"Crop rectangle out of bounds: {width}x{height} at ({x}, {y}) exceeds image size {img_width}x{img_height}"
));
}
let cropped = imageops::crop_imm(&self.document, x, y, width, height).to_image();
self.document = DynamicImage::ImageRgba8(cropped);
self.native_width = width;
self.native_height = height;
self.transform = TransformState::default();
self.refresh_handle();
Ok(())
}
/// Crop the image to the specified rectangle and return as DynamicImage.
///
/// This does NOT modify the document - it's used for exporting cropped images.
pub fn crop_to_image(
&self,
x: u32,
y: u32,
width: u32,
height: u32,
) -> DocResult<DynamicImage> {
let (img_width, img_height) = self.document.dimensions();
if x + width > img_width || y + height > img_height {
return Err(anyhow::anyhow!(
"Crop rectangle out of bounds: {width}x{height} at ({x}, {y}) exceeds image size {img_width}x{img_height}"
));
}
let cropped = imageops::crop_imm(&self.document, x, y, width, height).to_image();
Ok(DynamicImage::ImageRgba8(cropped))
}
}
// ============================================================================

View file

@ -22,15 +22,12 @@ pub fn set_as_wallpaper(path: &Path) {
}
};
let path_str = match abs_path.to_str() {
Some(s) => s,
None => {
log::error!("Invalid UTF-8 in path: {}", abs_path.display());
return;
}
let Some(path_str) = abs_path.to_str() else {
log::error!("Invalid UTF-8 in path: {}", abs_path.display());
return;
};
log::info!("Attempting to set wallpaper: {}", path_str);
log::info!("Attempting to set wallpaper: {path_str}");
// Method 1: Try COSMIC Desktop (direct config file modification).
if try_cosmic_wallpaper(path_str) {
@ -69,23 +66,22 @@ fn try_cosmic_wallpaper(path_str: &str) -> bool {
let config_content = format!(
r#"(
output: "all",
source: Path("{}"),
source: Path("{path_str}"),
filter_by_theme: true,
rotation_frequency: 300,
filter_method: Lanczos,
scaling_mode: Zoom,
sampling_method: Alphanumeric,
)"#,
path_str
)"#
);
match std::fs::write(&cosmic_config, config_content) {
Ok(_) => {
Ok(()) => {
log::info!("Wallpaper set via COSMIC config");
true
}
Err(e) => {
log::warn!("Failed to write COSMIC config: {}", e);
log::warn!("Failed to write COSMIC config: {e}");
false
}
}
@ -94,12 +90,12 @@ fn try_cosmic_wallpaper(path_str: &str) -> bool {
/// Try setting wallpaper via wallpaper crate.
fn try_wallpaper_crate(path_str: &str) -> bool {
match wallpaper::set_from_path(path_str) {
Ok(_) => {
Ok(()) => {
log::info!("Wallpaper set via wallpaper crate");
true
}
Err(e) => {
log::warn!("wallpaper crate failed: {}", e);
log::warn!("wallpaper crate failed: {e}");
false
}
}
@ -107,7 +103,7 @@ fn try_wallpaper_crate(path_str: &str) -> bool {
/// Try setting wallpaper via GNOME gsettings.
fn try_gsettings_wallpaper(path_str: &str) -> bool {
let uri = format!("file://{}", path_str);
let uri = format!("file://{path_str}");
let output = match std::process::Command::new("gsettings")
.args(["set", "org.gnome.desktop.background", "picture-uri", &uri])
@ -115,7 +111,7 @@ fn try_gsettings_wallpaper(path_str: &str) -> bool {
{
Ok(o) => o,
Err(e) => {
log::warn!("gsettings command failed: {}", e);
log::warn!("gsettings command failed: {e}");
return false;
}
};
@ -145,15 +141,12 @@ fn try_gsettings_wallpaper(path_str: &str) -> bool {
/// Try setting wallpaper via feh.
fn try_feh_wallpaper(path_str: &str) -> bool {
let output = match std::process::Command::new("feh")
let Ok(output) = std::process::Command::new("feh")
.args(["--bg-scale", path_str])
.output()
{
Ok(o) => o,
Err(_) => {
log::warn!("feh not available");
return false;
}
else {
log::warn!("feh not available");
return false;
};
if output.status.success() {

View file

@ -55,7 +55,7 @@ impl VectorDocument {
// Render at native scale (1.0).
let (rendered, width, height) =
render_document(&document, native_width, native_height, 1.0, &transform)?;
render_document(&document, native_width, native_height, 1.0, transform)?;
let handle = super::create_image_handle_from_image(&rendered);
Ok(Self {
@ -90,7 +90,7 @@ impl VectorDocument {
self.native_width,
self.native_height,
scale,
&self.transform,
self.transform,
) {
Ok((rendered, width, height)) => {
self.current_scale = scale;
@ -101,7 +101,7 @@ impl VectorDocument {
true
}
Err(e) => {
log::error!("Failed to re-render SVG at scale {}: {}", scale, e);
log::error!("Failed to re-render SVG at scale {scale}: {e}");
false
}
}
@ -114,7 +114,7 @@ impl VectorDocument {
self.native_width,
self.native_height,
self.current_scale,
&self.transform,
self.transform,
) {
self.rendered = rendered;
self.width = width;
@ -178,12 +178,12 @@ fn render_document(
native_width: u32,
native_height: u32,
scale: f64,
transform: &TransformState,
transform: TransformState,
) -> anyhow::Result<(DynamicImage, u32, u32)> {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let width = (((native_width as f64) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
let width = ((f64::from(native_width) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let height = (((native_height as f64) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
let height = ((f64::from(native_height) * scale).ceil() as u32).max(MIN_PIXMAP_SIZE);
let mut pixmap =
Pixmap::new(width, height).ok_or_else(|| anyhow::anyhow!("Failed to create pixmap"))?;