feature: PDF and PDF thumbnails and refresh UI
- Implement PDF and PDF thumbnail generation with incremental loading - Add UI refresh mechanism (tick counter + RefreshView message) - Improve fl! macro with named parameters - Clean up code organization (mod.rs: wiring, model.rs: state only)
This commit is contained in:
parent
220a886acc
commit
1182b7b55d
30 changed files with 1929 additions and 691 deletions
|
|
@ -1,71 +1,317 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// src/app/document/portable.rs
|
||||
//
|
||||
// Portable documents (e.g. PDF) – basic model and rendering stub.
|
||||
// Portable documents (PDF) with poppler backend.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use cosmic::iced::widget::image as iced_image;
|
||||
use image::{GenericImageView, DynamicImage};
|
||||
use cairo::{Context, Format, ImageSurface};
|
||||
use image::{imageops, DynamicImage, ImageReader};
|
||||
use poppler::PopplerDocument;
|
||||
|
||||
use super::{cache, ImageHandle};
|
||||
use crate::constant::{FULL_ROTATION, PDF_RENDER_SCALE, PDF_THUMBNAIL_SCALE, ROTATION_STEP};
|
||||
|
||||
/// Represents a portable document (PDF).
|
||||
pub struct PortableDocument {
|
||||
pub path: PathBuf,
|
||||
pub page_count: u32,
|
||||
pub current_page: u32,
|
||||
pub rotation: i32, // 0, 90, 180, 270; kept for future backend integration
|
||||
/// The parsed PDF document.
|
||||
document: PopplerDocument,
|
||||
/// Path to the source file (for caching).
|
||||
source_path: PathBuf,
|
||||
/// Total number of pages.
|
||||
page_count: u32,
|
||||
/// Current page index (0-based).
|
||||
current_page: u32,
|
||||
/// Rotation in degrees (0, 90, 180, 270).
|
||||
pub rotation: i16,
|
||||
/// Current rendered page as image.
|
||||
pub rendered: DynamicImage,
|
||||
pub handle: iced_image::Handle,
|
||||
// TODO: internal PDF handle from chosen backend
|
||||
/// Image handle for display.
|
||||
pub handle: ImageHandle,
|
||||
/// Cached thumbnail handles for each page (None = not yet generated).
|
||||
thumbnail_cache: Option<Vec<ImageHandle>>,
|
||||
}
|
||||
|
||||
impl PortableDocument {
|
||||
/// Open a portable document and render the first page.
|
||||
///
|
||||
/// Currently this uses a dummy 1x1 transparent image as placeholder.
|
||||
pub fn open(path: PathBuf) -> anyhow::Result<Self> {
|
||||
// TODO: open PDF and render first page using a proper backend.
|
||||
let dummy = DynamicImage::new_rgba8(1, 1);
|
||||
let handle = Self::build_handle(&dummy);
|
||||
/// 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))?;
|
||||
|
||||
let page_count = document.get_n_pages() as u32;
|
||||
if page_count == 0 {
|
||||
return Err(anyhow::anyhow!("PDF has no pages"));
|
||||
}
|
||||
|
||||
let rendered = Self::render_page(&document, 0, 0)?;
|
||||
let handle = super::create_image_handle(&rendered);
|
||||
|
||||
Ok(Self {
|
||||
path,
|
||||
page_count: 1, // TODO: query real page count from backend
|
||||
document,
|
||||
source_path: path.to_path_buf(),
|
||||
page_count,
|
||||
current_page: 0,
|
||||
rotation: 0,
|
||||
rendered: dummy,
|
||||
rendered,
|
||||
handle,
|
||||
thumbnail_cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct an iced image handle from a DynamicImage.
|
||||
fn build_handle(img: &DynamicImage) -> iced_image::Handle {
|
||||
let (w, h) = img.dimensions();
|
||||
let rgba = img.to_rgba8();
|
||||
let pixels = rgba.into_raw();
|
||||
iced_image::Handle::from_rgba(w, h, pixels)
|
||||
/// Check if all thumbnails are ready.
|
||||
pub fn thumbnails_ready(&self) -> bool {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.map(|c| c.len() as u32 >= self.page_count)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Get the number of thumbnails currently loaded.
|
||||
pub fn thumbnails_loaded(&self) -> u32 {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.map(|c| c.len() as u32)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Initialize thumbnail cache (empty, ready for incremental loading).
|
||||
pub fn init_thumbnail_cache(&mut self) {
|
||||
if self.thumbnail_cache.is_none() {
|
||||
self.thumbnail_cache = Some(Vec::with_capacity(self.page_count as usize));
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a single thumbnail page. Returns the next page to generate, or None if done.
|
||||
pub fn generate_thumbnail_page(&mut self, page: u32) -> Option<u32> {
|
||||
// Initialize cache if needed.
|
||||
self.init_thumbnail_cache();
|
||||
|
||||
// Check if we should generate this page.
|
||||
let should_generate = {
|
||||
let cache = self.thumbnail_cache.as_ref()?;
|
||||
page as usize >= cache.len() && page < self.page_count
|
||||
};
|
||||
|
||||
if should_generate {
|
||||
let handle = self.load_or_generate_thumbnail(page);
|
||||
if let Some(cache) = self.thumbnail_cache.as_mut() {
|
||||
cache.push(handle);
|
||||
}
|
||||
}
|
||||
|
||||
// Return next page if not done.
|
||||
let next = page + 1;
|
||||
if next < self.page_count {
|
||||
Some(next)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate all thumbnails at once (legacy, blocking).
|
||||
pub fn generate_thumbnails(&mut self) {
|
||||
if self.thumbnails_ready() {
|
||||
return;
|
||||
}
|
||||
self.init_thumbnail_cache();
|
||||
for page in 0..self.page_count {
|
||||
self.generate_thumbnail_page(page);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load thumbnail from cache or generate and cache it.
|
||||
fn load_or_generate_thumbnail(&self, page: u32) -> ImageHandle {
|
||||
if let Some(handle) = cache::load_thumbnail(&self.source_path, page) {
|
||||
return handle;
|
||||
}
|
||||
|
||||
match Self::render_page_at_scale(&self.document, page, 0, PDF_THUMBNAIL_SCALE) {
|
||||
Ok(img) => {
|
||||
let _ = cache::save_thumbnail(&self.source_path, page, &img);
|
||||
super::create_image_handle(&img)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to generate thumbnail for page {}: {}", page, e);
|
||||
ImageHandle::from_rgba(1, 1, vec![0, 0, 0, 0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a specific page from the document to an image.
|
||||
fn render_page(
|
||||
document: &PopplerDocument,
|
||||
page_index: u32,
|
||||
rotation: i16,
|
||||
) -> anyhow::Result<DynamicImage> {
|
||||
Self::render_page_at_scale(document, page_index, rotation, PDF_RENDER_SCALE)
|
||||
}
|
||||
|
||||
/// Render a specific page at a given scale.
|
||||
fn render_page_at_scale(
|
||||
document: &PopplerDocument,
|
||||
page_index: u32,
|
||||
rotation: i16,
|
||||
scale: f64,
|
||||
) -> anyhow::Result<DynamicImage> {
|
||||
let page = document
|
||||
.get_page(page_index as usize)
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to get page {}", page_index))?;
|
||||
|
||||
let (page_width, page_height) = page.get_size();
|
||||
|
||||
let (width, height) = if rotation == 90 || rotation == 270 {
|
||||
(page_height, page_width)
|
||||
} else {
|
||||
(page_width, page_height)
|
||||
};
|
||||
|
||||
let scaled_width = (width * scale) as i32;
|
||||
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))?;
|
||||
|
||||
let context = Context::new(&surface)
|
||||
.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);
|
||||
let _ = context.paint();
|
||||
|
||||
context.scale(scale, scale);
|
||||
|
||||
if rotation != 0 {
|
||||
let center_x = width / 2.0;
|
||||
let center_y = height / 2.0;
|
||||
context.translate(center_x, center_y);
|
||||
context.rotate(f64::from(rotation) * std::f64::consts::PI / 180.0);
|
||||
context.translate(-page_width / 2.0, -page_height / 2.0);
|
||||
}
|
||||
|
||||
page.render(&context);
|
||||
|
||||
drop(context);
|
||||
surface.flush();
|
||||
|
||||
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))?;
|
||||
|
||||
let image = ImageReader::new(Cursor::new(png_data))
|
||||
.with_guessed_format()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read PNG format: {}", e))?
|
||||
.decode()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to decode PNG: {}", e))?;
|
||||
|
||||
Ok(image)
|
||||
}
|
||||
|
||||
/// Re-render the current page.
|
||||
fn rerender(&mut self) {
|
||||
match Self::render_page(&self.document, self.current_page, self.rotation) {
|
||||
Ok(rendered) => {
|
||||
self.rendered = rendered;
|
||||
self.refresh_handle();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to render PDF page: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the handle after mutating `rendered`.
|
||||
pub fn refresh_handle(&mut self) {
|
||||
self.handle = Self::build_handle(&self.rendered);
|
||||
self.handle = super::create_image_handle(&self.rendered);
|
||||
}
|
||||
|
||||
/// Returns the dimensions of the currently rendered page.
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
self.rendered.dimensions()
|
||||
(self.rendered.width(), self.rendered.height())
|
||||
}
|
||||
|
||||
/// Re-render the current page with the current rotation.
|
||||
pub fn rerender_page(&mut self) {
|
||||
// TODO: use PDF backend and self.rotation / self.current_page
|
||||
// self.rendered = render_page_to_dynamic(...);
|
||||
// self.refresh_handle();
|
||||
/// Navigate to a specific page.
|
||||
pub fn goto_page(&mut self, page: u32) -> anyhow::Result<()> {
|
||||
if page >= self.page_count {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Page {} out of range (0-{})",
|
||||
page,
|
||||
self.page_count - 1
|
||||
));
|
||||
}
|
||||
self.current_page = page;
|
||||
self.rerender();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Navigate to the next page.
|
||||
pub fn next_page(&mut self) -> bool {
|
||||
if self.current_page + 1 < self.page_count {
|
||||
self.current_page += 1;
|
||||
self.rerender();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the previous page.
|
||||
pub fn prev_page(&mut self) -> bool {
|
||||
if self.current_page > 0 {
|
||||
self.current_page -= 1;
|
||||
self.rerender();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees clockwise.
|
||||
pub fn rotate_cw(&mut self) {
|
||||
self.rotation = (self.rotation + ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Rotate 90 degrees counter-clockwise.
|
||||
pub fn rotate_ccw(&mut self) {
|
||||
self.rotation = (self.rotation - ROTATION_STEP).rem_euclid(FULL_ROTATION);
|
||||
self.rerender();
|
||||
}
|
||||
|
||||
/// Flip horizontally.
|
||||
pub fn flip_horizontal(&mut self) {
|
||||
self.rendered = DynamicImage::ImageRgba8(imageops::flip_horizontal(&self.rendered));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Flip vertically.
|
||||
pub fn flip_vertical(&mut self) {
|
||||
self.rendered = DynamicImage::ImageRgba8(imageops::flip_vertical(&self.rendered));
|
||||
self.refresh_handle();
|
||||
}
|
||||
|
||||
/// Extract metadata for this portable document.
|
||||
pub fn extract_meta(&self) -> super::meta::DocumentMeta {
|
||||
pub fn extract_meta(&self, path: &Path) -> super::meta::DocumentMeta {
|
||||
let (width, height) = self.dimensions();
|
||||
super::meta::build_portable_meta(path, width, height, self.page_count)
|
||||
}
|
||||
|
||||
super::meta::build_portable_meta(&self.path, width, height, self.page_count)
|
||||
/// Get total page count.
|
||||
pub fn page_count(&self) -> u32 {
|
||||
self.page_count
|
||||
}
|
||||
|
||||
/// Get current page index (0-based).
|
||||
pub fn current_page(&self) -> u32 {
|
||||
self.current_page
|
||||
}
|
||||
|
||||
/// Get cached thumbnail handle for a specific page.
|
||||
/// Returns None if thumbnails not yet generated.
|
||||
pub fn get_thumbnail(&self, page: u32) -> Option<ImageHandle> {
|
||||
self.thumbnail_cache
|
||||
.as_ref()
|
||||
.and_then(|cache| cache.get(page as usize).cloned())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue