// SPDX-License-Identifier: GPL-3.0-or-later // src/app/document/file.rs // // Opening files, folder scanning, and navigation helpers. use std::fs; use std::path::{Path, PathBuf}; use anyhow::anyhow; use super::portable::PortableDocument; use super::raster::RasterDocument; use super::vector::VectorDocument; use super::{DocumentContent, DocumentKind}; use crate::app::model::{AppModel, ViewMode}; /// Open a document from a file path and dispatch to the correct type. /// /// Raster formats are delegated to the `image` crate, which decides /// based on enabled codecs (e.g. default-formats). pub fn open_document(path: &Path) -> anyhow::Result { let kind = DocumentKind::from_path(path) .ok_or_else(|| anyhow!("Unsupported document type: {}", path.display()))?; let content = match kind { DocumentKind::Raster => { let raster = RasterDocument::open(path)?; DocumentContent::Raster(raster) } DocumentKind::Vector => { let vector = VectorDocument::open(path)?; DocumentContent::Vector(vector) } DocumentKind::Portable => { let portable = PortableDocument::open(path)?; DocumentContent::Portable(portable) } }; Ok(content) } /// Open the initial path passed on the command line. /// /// 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) { if path.is_dir() { open_from_directory(model, path); } else { open_single_file(model, path); } } /// Open the first supported document from the given directory and /// populate folder navigation state. pub fn open_from_directory(model: &mut AppModel, dir: &Path) { let entries = collect_supported_files(dir); if entries.is_empty() { model.set_error(format!( "No supported documents found in directory: {}", dir.display() )); return; } let first = entries[0].clone(); model.folder_entries = entries; model.current_index = Some(0); load_document_into_model(model, &first); } /// Open a single file, update current path and refresh folder entries. pub fn open_single_file(model: &mut AppModel, path: &Path) { load_document_into_model(model, path); // Refresh folder listing based on parent directory. if model.document.is_some() && let Some(parent) = path.parent() { refresh_folder_entries(model, parent, path); } } /// Load a document into the model, resetting view state. fn load_document_into_model(model: &mut AppModel, path: &Path) { match open_document(path) { Ok(doc) => { // Extract metadata before storing the document. let metadata = doc.extract_meta(path); model.document = Some(doc); model.metadata = Some(metadata); model.current_path = Some(path.to_path_buf()); model.clear_error(); // Reset view state for new document. model.reset_pan(); model.view_mode = ViewMode::Fit; } Err(err) => { model.document = None; model.metadata = None; model.current_path = None; model.set_error(err.to_string()); } } } /// Refresh the `folder_entries` list and current index based on the /// given folder and currently active file. pub fn refresh_folder_entries(model: &mut AppModel, folder: &Path, current: &Path) { let entries = collect_supported_files(folder); // Determine current index. let current_index = entries.iter().position(|p| p == current); model.folder_entries = entries; model.current_index = current_index; } /// Collect all supported document files from a directory, sorted alphabetically. fn collect_supported_files(dir: &Path) -> Vec { let mut entries: Vec = Vec::new(); if let Ok(read_dir) = fs::read_dir(dir) { for entry in read_dir.flatten() { let path = entry.path(); // Only keep regular files that are recognized as supported documents. if path.is_file() && DocumentKind::from_path(&path).is_some() { entries.push(path); } } } entries.sort(); entries } /// Navigate to the next document in the folder. pub fn navigate_next(model: &mut AppModel) { if model.folder_entries.is_empty() { return; } let new_index = match model.current_index { Some(idx) => { if idx + 1 < model.folder_entries.len() { idx + 1 } else { 0 // Wrap around to first. } } None => 0, }; if let Some(path) = model.folder_entries.get(new_index).cloned() { model.current_index = Some(new_index); load_document_into_model(model, &path); } } /// Navigate to the previous document in the folder. pub fn navigate_prev(model: &mut AppModel) { if model.folder_entries.is_empty() { return; } let new_index = match model.current_index { Some(idx) => { if idx > 0 { idx - 1 } else { model.folder_entries.len() - 1 // Wrap around to last. } } None => model.folder_entries.len().saturating_sub(1), }; if let Some(path) = model.folder_entries.get(new_index).cloned() { model.current_index = Some(new_index); load_document_into_model(model, &path); } } // --------------------------------------------------------------------------- // File metadata helpers // --------------------------------------------------------------------------- /// Retrieve the file size in bytes. Returns 0 if the file cannot be accessed. pub fn file_size(path: &Path) -> u64 { fs::metadata(path).map(|m| m.len()).unwrap_or(0) } /// Read raw bytes from a file for metadata extraction (e.g., EXIF). /// Returns None if the file cannot be read. pub fn read_file_bytes(path: &Path) -> Option> { 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 { 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) }