grid: add PDF thumbnail support
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
bc72349b0b7c025e8853280cd039e75345576210- Parents
-
1d6e7be - Tree
2aebf8d
bc72349
bc72349b0b7c025e8853280cd039e753455762101d6e7be
2aebf8d| Status | File | + | - |
|---|---|---|---|
| M |
garfield/src/core/thumbnail.rs
|
79 | 0 |
| M |
garfield/src/ui/grid_view.rs
|
10 | 7 |
garfield/src/core/thumbnail.rsmodified@@ -91,6 +91,20 @@ impl ThumbnailLoader { | |||
| 91 | 91 | ||
| 92 | /// Load and scale a thumbnail. | 92 | /// Load and scale a thumbnail. |
| 93 | fn load_thumbnail(path: &PathBuf, size: u32) -> Option<Thumbnail> { | 93 | fn load_thumbnail(path: &PathBuf, size: u32) -> Option<Thumbnail> { |
| 94 | + // Check if it's a PDF | ||
| 95 | + let ext = path.extension() | ||
| 96 | + .and_then(|e| e.to_str()) | ||
| 97 | + .map(|e| e.to_lowercase()); | ||
| 98 | + | ||
| 99 | + if ext.as_deref() == Some("pdf") { | ||
| 100 | + return Self::load_pdf_thumbnail(path, size); | ||
| 101 | + } | ||
| 102 | + | ||
| 103 | + Self::load_image_thumbnail(path, size) | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + /// Load thumbnail from an image file. | ||
| 107 | + fn load_image_thumbnail(path: &PathBuf, size: u32) -> Option<Thumbnail> { | ||
| 94 | use image::GenericImageView; | 108 | use image::GenericImageView; |
| 95 | 109 | ||
| 96 | let img = image::open(path).ok()?; | 110 | let img = image::open(path).ok()?; |
@@ -118,6 +132,71 @@ impl ThumbnailLoader { | |||
| 118 | }) | 132 | }) |
| 119 | } | 133 | } |
| 120 | 134 | ||
| 135 | + /// Load thumbnail from a PDF file (render first page). | ||
| 136 | + fn load_pdf_thumbnail(path: &PathBuf, size: u32) -> Option<Thumbnail> { | ||
| 137 | + use cairo::{Context, Format, ImageSurface}; | ||
| 138 | + use poppler::Document; | ||
| 139 | + | ||
| 140 | + // Load the PDF document | ||
| 141 | + let uri = format!("file://{}", path.display()); | ||
| 142 | + let doc = Document::from_file(&uri, None).ok()?; | ||
| 143 | + | ||
| 144 | + if doc.n_pages() == 0 { | ||
| 145 | + return None; | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + // Get the first page | ||
| 149 | + let page = doc.page(0)?; | ||
| 150 | + let (page_width, page_height) = page.size(); | ||
| 151 | + | ||
| 152 | + // Calculate scale to fit within thumbnail size | ||
| 153 | + let scale = (size as f64 / page_width).min(size as f64 / page_height); | ||
| 154 | + let width = (page_width * scale) as i32; | ||
| 155 | + let height = (page_height * scale) as i32; | ||
| 156 | + | ||
| 157 | + // Create a Cairo surface to render to | ||
| 158 | + let mut surface = ImageSurface::create(Format::ARgb32, width, height).ok()?; | ||
| 159 | + | ||
| 160 | + { | ||
| 161 | + let ctx = Context::new(&surface).ok()?; | ||
| 162 | + | ||
| 163 | + // Fill with white background | ||
| 164 | + ctx.set_source_rgb(1.0, 1.0, 1.0); | ||
| 165 | + ctx.paint().ok()?; | ||
| 166 | + | ||
| 167 | + // Scale and render the page | ||
| 168 | + ctx.scale(scale, scale); | ||
| 169 | + page.render(&ctx); | ||
| 170 | + } | ||
| 171 | + | ||
| 172 | + // Get the pixel data | ||
| 173 | + surface.flush(); | ||
| 174 | + let stride = surface.stride() as usize; | ||
| 175 | + let data = surface.data().ok()?; | ||
| 176 | + | ||
| 177 | + // Convert from ARGB (Cairo) to RGBA | ||
| 178 | + let mut rgba = Vec::with_capacity((width * height * 4) as usize); | ||
| 179 | + for y in 0..height as usize { | ||
| 180 | + for x in 0..width as usize { | ||
| 181 | + let offset = y * stride + x * 4; | ||
| 182 | + let b = data[offset]; | ||
| 183 | + let g = data[offset + 1]; | ||
| 184 | + let r = data[offset + 2]; | ||
| 185 | + let a = data[offset + 3]; | ||
| 186 | + rgba.push(r); | ||
| 187 | + rgba.push(g); | ||
| 188 | + rgba.push(b); | ||
| 189 | + rgba.push(a); | ||
| 190 | + } | ||
| 191 | + } | ||
| 192 | + | ||
| 193 | + Some(Thumbnail { | ||
| 194 | + data: rgba, | ||
| 195 | + width: width as u32, | ||
| 196 | + height: height as u32, | ||
| 197 | + }) | ||
| 198 | + } | ||
| 199 | + | ||
| 121 | /// Get a cached thumbnail, or request loading if not cached. | 200 | /// Get a cached thumbnail, or request loading if not cached. |
| 122 | /// Returns Some(thumbnail) if cached, None if loading or not an image. | 201 | /// Returns Some(thumbnail) if cached, None if loading or not an image. |
| 123 | pub fn get_or_load(&mut self, path: &PathBuf) -> Option<&Thumbnail> { | 202 | pub fn get_or_load(&mut self, path: &PathBuf) -> Option<&Thumbnail> { |
garfield/src/ui/grid_view.rsmodified@@ -1,6 +1,6 @@ | |||
| 1 | //! Grid/icon view for displaying directory contents. | 1 | //! Grid/icon view for displaying directory contents. |
| 2 | 2 | ||
| 3 | -use crate::core::{is_supported_image, EntryType, FileEntry, SortDirection, SortOrder, ThumbnailLoader}; | 3 | +use crate::core::{is_supported_image, is_pdf, EntryType, FileEntry, SortDirection, SortOrder, ThumbnailLoader}; |
| 4 | use crate::ui::tab::RenameState; | 4 | use crate::ui::tab::RenameState; |
| 5 | use gartk_core::{Color, Modifiers, Point, Rect}; | 5 | use gartk_core::{Color, Modifiers, Point, Rect}; |
| 6 | use gartk_render::{Renderer, Surface, TextAlign, TextStyle}; | 6 | use gartk_render::{Renderer, Surface, TextAlign, TextStyle}; |
@@ -205,7 +205,7 @@ impl GridView { | |||
| 205 | any_loaded | 205 | any_loaded |
| 206 | } | 206 | } |
| 207 | 207 | ||
| 208 | - /// Request thumbnails for visible image files. | 208 | + /// Request thumbnails for visible image and PDF files. |
| 209 | pub fn request_visible_thumbnails(&mut self) { | 209 | pub fn request_visible_thumbnails(&mut self) { |
| 210 | let visible_count = self.visible_count(); | 210 | let visible_count = self.visible_count(); |
| 211 | let visible_rows = self.visible_rows(); | 211 | let visible_rows = self.visible_rows(); |
@@ -216,9 +216,10 @@ impl GridView { | |||
| 216 | let paths_to_load: Vec<PathBuf> = (start_index..end_index) | 216 | let paths_to_load: Vec<PathBuf> = (start_index..end_index) |
| 217 | .filter_map(|i| { | 217 | .filter_map(|i| { |
| 218 | self.visible_entry(i).and_then(|entry| { | 218 | self.visible_entry(i).and_then(|entry| { |
| 219 | - if is_supported_image(entry.extension().as_deref()) | 219 | + let ext_owned = entry.extension(); |
| 220 | - && !self.thumbnail_cache.contains_key(&entry.path) | 220 | + let ext = ext_owned.as_deref(); |
| 221 | - { | 221 | + let is_thumbnailable = is_supported_image(ext) || is_pdf(ext); |
| 222 | + if is_thumbnailable && !self.thumbnail_cache.contains_key(&entry.path) { | ||
| 222 | Some(entry.path.clone()) | 223 | Some(entry.path.clone()) |
| 223 | } else { | 224 | } else { |
| 224 | None | 225 | None |
@@ -701,9 +702,11 @@ impl GridView { | |||
| 701 | renderer.stroke_rect(cell, theme.selection_background.with_alpha(0.5), 1.0)?; | 702 | renderer.stroke_rect(cell, theme.selection_background.with_alpha(0.5), 1.0)?; |
| 702 | } | 703 | } |
| 703 | 704 | ||
| 704 | - // Check for cached thumbnail first (for image files) | 705 | + // Check for cached thumbnail first (for image and PDF files) |
| 705 | let mut rendered_thumbnail = false; | 706 | let mut rendered_thumbnail = false; |
| 706 | - if is_supported_image(entry.extension().as_deref()) { | 707 | + let ext_owned = entry.extension(); |
| 708 | + let ext = ext_owned.as_deref(); | ||
| 709 | + if is_supported_image(ext) || is_pdf(ext) { | ||
| 707 | if let Some(surface) = self.thumbnail_cache.get(&entry.path) { | 710 | if let Some(surface) = self.thumbnail_cache.get(&entry.path) { |
| 708 | // Render the thumbnail centered in the icon area | 711 | // Render the thumbnail centered in the icon area |
| 709 | let thumb_w = surface.width(); | 712 | let thumb_w = surface.width(); |