用rust+slint编写一个pdf阅读器2

目录

先说说解码服务

先看一下数据结构

然后就是解码服务的实现

解码循环:

队列处理

解码成功,向外发送结果

加载文档

解码的请求方法:

解码器

解码页面

解码器的结构

状态管理类

属性

打开文档,初始化pages

渲染

update_view_size

可见页的计算

ui显示

渲染

documentview

总结


阅读器已经初步完成了核心功能.两三周的时间奋斗,看到效果还不错的.后续添加功能没那么复杂了.

先说说解码服务

解码器目前有mupdf,用于pdf,epub,mobi这些解码.性能高,基本没有看到更强的免费产品了.

tiff解码,需要自己编译,android的tiff解码器编译容易,然后已经用上了.这个tiff并不是市面上容易看到的,解码一张几mb大小的,crate中是有,但不能解码大图,而是能轻松快速地解码4gb的图片的.所以要自己编译,优化解码.一般的最多300mb就会内存溢出了.更别说支持4gb以上的.mupdf手机上测试,最多也是600mb就会停止解码,代码里面写的很清楚,这是一个压缩炸弹.我测试过最大3gb,清明上河图tiff还是看压缩方式,lzw就慢.

djvu,heic之前桌面端是自己编译的,我看是有相关的crate的,后面看能不能直接用了.

针对不同的解码需求,那么是根据文档类型来判断应该加载什么解码器,这些解码器,估计都不能在线程间传递,所以一律在解码线程中初始化了.

所以需要用到use crossbeam_channel::{unbounded, Sender, Receiver}.crossbeam_channel这个库,实现向线程发送数据,在线程外接收数据.避免了解码器在不同的线程间传递.

先看一下数据结构

rust 复制代码
#[derive(Clone, Debug)]
pub struct RenderPage {
    pub key: String,
    pub page_info: PageInfo,
    pub crop: i32,
    pub priority: Priority,
}

impl Hash for RenderPage {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.key.hash(state);
    }
}

impl PartialEq for RenderPage {
    fn eq(&self, other: &Self) -> bool {
        self.key == other.key
            && self.page_info.index == other.page_info.index
            && (self.page_info.width - other.page_info.width).abs() < 0.1
            && (self.page_info.height - other.page_info.height).abs() < 0.1
            && (self.page_info.scale - other.page_info.scale).abs() < 0.001
            && self.crop == other.crop
    }
}

impl Eq for RenderPage {}

/// 解码任务
pub enum DecodeTask {
    /// 加载文档
    LoadDocument {
        path: PathBuf,
        response_tx: Sender<Result<Vec<PageInfo>>>,
    },
    /// 批量渲染页面
    RenderPages {
        pages: Vec<RenderPage>,
    },
    /// 获取大纲
    GetOutline {
        response_tx: Sender<Result<Vec<crate::entity::OutlineItem>>>,
    },
    /// 关闭服务
    Shutdown,
}

/// 解码结果(原始数据,可以跨线程传递)
pub struct DecodeResult {
    pub key: String,
    pub page_info: PageInfo,
    pub image_data: Vec<u8>,
    pub image_width: u32,
    pub image_height: u32,
    pub links: Vec<Link>,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Priority {
    Thumbnail = 0, // 最高优先级
    FullImage = 1, // 中优先级
    Cropped = 2,   // 低优先级
}

/// 解码服务 - 单线程解码,通过channel通信
pub struct DecodeService {
    task_sender: Sender<DecodeTask>,
    result_receiver: Mutex<Receiver<DecodeResult>>,
    decode_thread: Option<JoinHandle<()>>,
}

然后就是解码服务的实现

rust 复制代码
impl DecodeService {
    pub fn new() -> Self {
        let (task_tx, task_rx) = unbounded::<DecodeTask>();
        let (result_tx, result_rx) = unbounded::<DecodeResult>();

        // 启动解码线程
        let decode_thread = thread::spawn(move || {
            Self::decode_loop(task_rx, result_tx);
        });

        Self {
            task_sender: task_tx,
            result_receiver: Mutex::new(result_rx),
            decode_thread: Some(decode_thread),
        }
    }

在这里建立一个线程,然后循环读取任务.建立两个通道,可以与线程通信.外部要向线程发送任务,线程解码完成后返回数据可以通过通道向外发送任务.这个设计不复杂,用ai搞半天,它还是乱搞一通.

解码循环:
rust 复制代码
fn decode_loop(task_rx: Receiver<DecodeTask>, result_tx: Sender<DecodeResult>) {
        let mut decoder: Option<Box<dyn Decoder>> = None;
        let mut task_queue: VecDeque<RenderPage> = VecDeque::new();
        let mut current_visible: HashSet<RenderPage> = HashSet::new();

        loop {
            // 1. 先检查是否有新任务(非阻塞)
            while let Ok(task) = task_rx.try_recv() {
                if Self::handle_task(
                    task,
                    &mut decoder,
                    &mut task_queue,
                    &mut current_visible,
                ) {
                    return;
                }
            }

            // 2. 处理队列中的一个任务
            if let Some(render_page) = task_queue.pop_front() {
                if !current_visible.contains(&render_page) {
                    info!("[DecodeService] 跳过不可见页: page={}, key={}", 
                        render_page.page_info.index, render_page.key);
                    continue;
                }

                if let Some(ref dec) = decoder {
                    let start_time = Instant::now();
                    
                    match dec.render_page(&render_page.page_info, render_page.crop != 0) {
                        Ok((image_data, width, height)) => {
                            let links = dec.get_page_links(render_page.page_info.index)
                                .unwrap_or_default();

                            let duration = start_time.elapsed();
                            info!(
                                "[DecodeService] 页面 {} 解码完成,耗时: {:?}, links: {}",
                                render_page.page_info.index, duration, links.len()
                            );

                            let result = DecodeResult {
                                key: render_page.key.clone(),
                                page_info: render_page.page_info.clone(),
                                image_data,
                                image_width: width,
                                image_height: height,
                                links,
                            };

                            if result_tx.send(result).is_err() {
                                info!("[DecodeService] Result channel closed");
                                return;
                            }
                        }
                        Err(e) => {
                            info!("[DecodeService] 页面 {} 解码失败: {}", render_page.page_info.index, e);
                        }
                    }
                }
                
                continue;
            }

            // 3. 队列为空,阻塞等待新任务
            match task_rx.recv() {
                Ok(task) => {
                    if Self::handle_task(
                        task,
                        &mut decoder,
                        &mut task_queue,
                        &mut current_visible,
                    ) {
                        break;
                    }
                }
                Err(_) => {
                    info!("[DecodeService] Task channel closed");
                    break;
                }
            }
        }
    }

| Channel | 用途 | 发送者 | 接收者 | 数据类型 |

|---------|------|--------|--------|----------|

| task_sender | 发送管理任务 | UI线程 | 解码线程 | DecodeTask |

| result_receiver | 接收解码结果 | 解码线程 | UI线程 | DecodeResult |

| response_tx | 同步响应 | 解码线程 | UI线程(临时) | 各种结果 |

这里的循环关键,接收到一个请求,然后将请求放入队列handle_task,接着开始针对 队列进行解码处理.解码一个完成后,continue;就会回到循环开始,这样便于优先处理外部的ui解码请求.这个设计是考虑到,当我滚动的时候,不断计算页面可见性,提交任务,快速滚动过后,需要优先处理这些入队列,而不是解码.因为有可能之前可见的现在不可见了.

队列处理
rust 复制代码
fn handle_task(
        task: DecodeTask,
        decoder: &mut Option<Box<dyn Decoder>>,
        task_queue: &mut VecDeque<RenderPage>,
        current_visible: &mut HashSet<RenderPage>,
    ) -> bool {
        match task {
            DecodeTask::LoadDocument { path, response_tx } => {
                info!("[DecodeService] Loading document: {:?}", path);
                match PdfDecoder::open(&path) {
                    Ok(pdf_decoder) => {
                        let boxed_decoder = Box::new(pdf_decoder);
                        let pages_result = boxed_decoder.get_all_pages();
                        *decoder = Some(boxed_decoder);
                        let first_page = if pages_result.is_ok() && !pages_result.as_ref().unwrap().is_empty() {
                            Some(pages_result.as_ref().unwrap()[0].clone())
                        } else {
                            None
                        };
                        let _ = response_tx.send(pages_result);
                        if let Some(fp) = first_page {
                            if let Some(ref dec) = decoder {
                                Self::save_cover_thumbnail(&path, dec, &fp);
                            }
                        }
                    }
                    Err(e) => {
                        let _ = response_tx.send(Err(e));
                    }
                }
                false
            }
            DecodeTask::RenderPages { pages } => {
                debug!("[DecodeService] 收到批量渲染任务: {} 页", pages.len());
                
                // 1. 更新当前可见页集合(用于后续验证)
                current_visible.clear();
                current_visible.extend(pages.iter().cloned());

                // 2. 将新任务加入队列(去重:检查队列中是否已存在相同key的任务)
                for page in pages {
                    let already_queued = task_queue.iter().any(|p| p.key == page.key);
                    if !already_queued {
                        debug!("[DecodeService] 加入队列: page={}, key={}", page.page_info.index, page.key);
                        task_queue.push_back(page);
                    } else {
                        info!("[DecodeService] 跳过重复任务: page={}, key={}", page.page_info.index, page.key);
                    }
                }
                
                info!("[DecodeService] 当前队列长度: {}, 可见页数: {}", 
                    task_queue.len(), current_visible.len());
                false
            }
            DecodeTask::GetOutline { response_tx } => {
                if let Some(ref dec) = decoder {
                    let outline_result = dec.get_outline_items();
                    let _ = response_tx.send(outline_result);
                } else {
                    let _ = response_tx.send(Ok(Vec::new()));
                }
                false
            }
            DecodeTask::Shutdown => {
                info!("[DecodeService] Shutting down decode thread");
                true
            }
        }
    }

这里的注释写的清楚了,循环开始,先处理任务,这里有几种任务类型,加载文档,获取大纲,与解码数据入队三类.

重点在解码入队,就是上面的循环中的重点了.入队是把此次可见的列表更新,这样如果之前还有队列的数据未解码,不是通过回调到ui线程去判断是不是可见,而是依据这个列表来判断是不是可见.这点还有待考虑.

把这次的解码请求的数据入队,然后在循环中按顺序解码,或按优先级(目前未实现)

解码成功,向外发送结果
rust 复制代码
pub fn try_recv_result(&self) -> Option<DecodeResult> {
        self.result_receiver.lock().unwrap().try_recv().ok()
    }

这里只管发,不管谁接收.

加载文档

采取的方式是阻塞ui的方式,就是与ui线程一样,等加载结果

rust 复制代码
pub fn load_pdf<P: AsRef<Path>>(&self, path: P) -> Result<Vec<PageInfo>> {
        let (response_tx, response_rx) = unbounded();
        self.task_sender
            .send(DecodeTask::LoadDocument {
                path: path.as_ref().to_path_buf(),
                response_tx,
            })
            .map_err(|e| anyhow::anyhow!("Failed to send load task: {}", e))?;

        response_rx
            .recv()
            .map_err(|e| anyhow::anyhow!("Failed to receive load response: {}", e))?
    }

虽然是阻塞,但不是在ui线程解码,依然是发送任务给解码线程,任务的类型是DecodeTask::LoadDocument,上面处理完这种类型的结果,再通过通道发送出来,这里建立了一个临时的通道,接收是阻塞的.然后返回给外部.

解码的请求方法:
rust 复制代码
pub fn render_pages(&self, pages: Vec<RenderPage>) {
        if !pages.is_empty() {
            let _ = self.task_sender.send(DecodeTask::RenderPages { pages });
        }
    }

到这里,解码服务就差不多了,还有销毁,析构等.相对于之前compose,这个服务要简单不少,之前的设计是三个队列,这样不需要优先队列,而是优先级高的队列处理完了才处理低的.然后分配任务的线程一个,解码线程一个,共两个线程.使用了kotlin协程的通道实现的类似操作.

解码器

解码器是要设计成多类型的支持的.

rust 复制代码
pub trait Decoder {
    /// 获取文档页数
    fn page_count(&self) -> usize;

    /// 获取页面原始尺寸
    fn get_page_size(&self, index: usize) -> anyhow::Result<(f32, f32)>;

    /// 获取所有页面信息
    fn get_all_pages(&self) -> anyhow::Result<Vec<PageInfo>>;

    /// 渲染完整页面,返回原始RGBA像素数据
    /// - page: 页面信息
    /// - crop: 是否使用切边
    fn render_page(&self, page: &PageInfo, crop: bool) -> anyhow::Result<(Vec<u8>, u32, u32)>;

    /// 渲染页面区域(用于分块渲染),返回原始RGBA像素数据
    /// - page_index: 页面索引
    /// - region: 要渲染的区域(PDF坐标系)
    /// - scale: 缩放比例
    fn render_region(
        &self,
        page_index: usize,
        region: Rect,
        scale: f32,
    ) -> anyhow::Result<(Vec<u8>, u32, u32)>;

    /// 获取页面链接
    fn get_page_links(&self, page_index: usize) -> anyhow::Result<Vec<Link>>;

    /// 获取页面文本(用于搜索/TTS)
    fn get_page_text(&self, page_index: usize) -> anyhow::Result<String>;

    fn get_outline_items(&self) -> anyhow::Result<Vec<OutlineItem>>;

    /// 关闭文档
    fn close(&mut self);
}

fn render_page(&self, page: &PageInfo, crop: bool) -> anyhow::Result<(Vec<u8>, u32, u32)>这个结果是考虑到,解码成功后需要转为slint或其它ui框架可用的数据,不如直接这样.原来是DynamicImage,再转换多次,性能有损耗.

解码页面

这部分就是调用mupdf

rust 复制代码
fn render_page(&self, page: &PageInfo, crop: bool) -> Result<(Vec<u8>, u32, u32)> {
        debug!("[PDF] Rendering page {} with crop={}", page.index, crop);
        let document = self.document.borrow();
        let mupdf_page = document.load_page(page.index as i32)?;

        let bounds = if crop && page.crop_bounds.is_some() {
            page.crop_bounds.unwrap()
        } else {
            let b = mupdf_page.bounds()?;
            Rect::new(b.x0, b.y0, b.x1, b.y1)
        };

        let scale = page.scale * 2.0; // DPI scale for retina
        let matrix = Matrix::new(scale, 0.0, 0.0, scale, 0.0, 0.0);

        let width = ((bounds.width()) * scale) as i32;
        let height = ((bounds.height()) * scale) as i32;

        let colorspace = Colorspace::device_rgb();
        let mut pixmap = Pixmap::new(&colorspace, 0, 0, width, height, true)?;
        pixmap.clear()?;

        let mut device = Device::from_pixmap(&pixmap)?;
        mupdf_page.run(&mut device, &matrix)?;

        Ok(mupdf_to_pixels(&pixmap))
    }

这是直接将一个page解码为一张图片了.然后转像素

rust 复制代码
pub fn mupdf_to_pixels(pixmap: &Pixmap) -> (Vec<u8>, u32, u32) {
    let width = pixmap.width() as u32;
    let height = pixmap.height() as u32;
    let samples = pixmap.samples();
    let n = pixmap.n() as usize; // 每个像素的组件数

    let mut buffer = vec![0u8; (width * height * 4) as usize];

    for y in 0..height {
        for x in 0..width {
            let src_idx = ((y * width + x) as usize) * n;
            let dst_idx = ((y * width + x) as usize) * 4;

            if src_idx + n <= samples.len() && dst_idx + 4 <= buffer.len() {
                if n == 4 {
                    // RGBA
                    buffer[dst_idx] = samples[src_idx];
                    buffer[dst_idx + 1] = samples[src_idx + 1];
                    buffer[dst_idx + 2] = samples[src_idx + 2];
                    buffer[dst_idx + 3] = samples[src_idx + 3];
                } else if n == 3 {
                    // RGB
                    buffer[dst_idx] = samples[src_idx];
                    buffer[dst_idx + 1] = samples[src_idx + 1];
                    buffer[dst_idx + 2] = samples[src_idx + 2];
                    buffer[dst_idx + 3] = 255;
                } else {
                    // 灰度或其他,复制到所有通道
                    buffer[dst_idx] = if n > 0 { samples[src_idx] } else { 255 };
                    buffer[dst_idx + 1] = buffer[dst_idx];
                    buffer[dst_idx + 2] = buffer[dst_idx];
                    buffer[dst_idx + 3] = if n > 1 { samples[src_idx + 1] } else { 255 };
                }
            }
        }
    }

    (buffer, width, height)
}

这些没什么特殊的.

解码器的结构

我觉得比上面的重要一些.关系到相关的设计,滚动,缩放等

rust 复制代码
pub struct PdfDecoder {
    document: RefCell<Document>,
    page_count: usize,
    pages_info: Vec<PageInfo>,
}
rust 复制代码
impl PdfDecoder {
    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
        info!("[PDF] Opening document: {:?}", path.as_ref());
        let mut document = Document::open(path.as_ref().to_str().unwrap())?;
        let path_str = path.as_ref().to_string_lossy().to_lowercase();
        if path_str.ends_with(".epub") || path_str.ends_with(".mobi") {
            document.layout(1024.0, 1280.0, 25.0)?;
        }
        let page_count = document.page_count()? as usize;
        info!("[PDF] Document opened with {} pages", page_count);

        // 预加载所有页面尺寸
        let mut pages_info = Vec::with_capacity(page_count);
        for i in 0..page_count {
            let page = document.load_page(i as i32)?;
            let bounds = page.bounds()?;
            let width = bounds.x1 - bounds.x0;
            let height = bounds.y1 - bounds.y0;
            pages_info.push(PageInfo::new(i, width, height));
        }

        Ok(Self {
            document: RefCell::new(document),
            page_count,
            pages_info,
        })
    }
}

这个设计是沿用vudroid当年的方式,一性次把所有的页面高宽全取出来.然后页面也是根据这个来计算的.后续所有的缩放,排列,都在这个基础上组织新页面.

解码器其它的部分就调用api就好了.

状态管理类

我也不太确定这么叫是不是合适.至少我在compose的实现上是这样的,所以直接翻译过来相应的实现就可以了

rust 复制代码
pub struct PageViewState {
    /// 页面缓存
    pub cache: Rc<PageCache>,

    /// 所有页面
    pub pages: Vec<Page>,

    /// 解码服务
    pub decode_service: Arc<DecodeService>,

    /// 滚动方向
    pub orientation: Orientation,

    /// 视图偏移 (x, y)
    pub view_offset: (f32, f32),

    /// 缩放比例
    pub zoom: f32,

    /// 是否启用切边
    pub crop: i32,

    /// 文档总宽度
    pub total_width: f32,

    /// 文档总高度
    pub total_height: f32,

    /// 视图尺寸 (width, height)
    pub view_size: (f32, f32),

    /// 预加载距离(屏幕数)
    pub preload_screens: f32,

    /// 当前可见页面索引列表
    pub visible_pages: Vec<usize>,

    /// 页面链接缓存,以页码为键存储链接列表
    pub page_links: Rc<RefCell<HashMap<usize, Vec<Link>>>>,

    pub outline_items: Vec<OutlineItem>,
}

属性

pub cache: Rc<PageCache> 这是用于判断当前页有没有在内存中缓存了图片了

pub pages: Vec<Page>,这个是与上面解码器返回的所有的页面生成的对应ui上的逻辑分页

pub view_offset: (f32, f32) 这是视图的偏移量,一旦滚动,比如向上拖动,y方向就是负的.通过这个计算当前应该显示哪页

上面几个比较重要.接着就是

打开文档,初始化pages

rust 复制代码
pub fn open_document<P: AsRef<Path>>(&mut self, path: P) -> anyhow::Result<()> {
        Self::reset(self);
        
        // 加载文档并获取页面信息
        let pages_info = self.decode_service.load_pdf(path)?;
        let pages: Vec<Page> = pages_info
            .into_iter()
            .map(|info: crate::decoder::PageInfo| Page::new(info, 0.0, 0.0, 0.0, 0.0))
            .collect();
        self.pages = pages;

        // 加载outline
        self.outline_items = self.decode_service.get_outline()?;
        
        Ok(())
    }

这里的page比较重要,生成这个后,后面的滚动,缩放,显示都必须依赖它.试想一下.一个page比如是1024*1024的,然后这时放大2,就是2048*2048,那么我直接在这个page上放大,后面的滚动就容易操作了.解码我只要根据2048*2048,得到它与原始页面的缩放就好处理了.compose还有分块,也是我根据这个页面逻辑切分块,比如512*512为一块,原来是4块,放大2倍后就是16块.解码的时候,只要知道块的相对当前page的位置,然后与缩放值计算出它的偏移就行了.

打开文档后,有了这些数据.那么如何触发开始渲染.

渲染

当然是由view触发的,这个类只是管理一些数据,状态,分发等.

由slint的on_viewport_changed触发视图的大小变化,还有打开文档on_open_file,缩放,滚动条等事件,发送到main.rs的监听.这些地方,调用

borrowed_state.update_view_size(width, height, zoom, false);//视图窗口变化触发的

borrowed_state.update_visible_pages();

我先设置整个视图窗口的大小,就是文档最多的渲染区域.然后更新文档的可见页.

update_view_size就是设置大小,然后就是是否有变化,有变化的话,先触发布局,这里不是真的ui布局,是逻辑上的排列

如果是滚动,或点击动条,可能是

page_view_state.update_offset(offset_x, offset_y);//更新偏移量.

page_view_state.update_visible_pages();

update_view_size

rust 复制代码
pub fn update_view_size(&mut self, width: f32, height: f32, zoom: f32, force: bool) {
        let size_changed = self.view_size.0 != width || self.view_size.1 != height;
        let zoom_changed = (self.zoom - zoom).abs() > 0.001;

        if !size_changed && !zoom_changed && !force {
            info!(
                "[PageViewState] don't update_view_size. w-h:{:?}-{:?}, zoom:{:?}",
                width, height, zoom
            );
            return;
        }

        self.view_size = (width, height);
        self.zoom = zoom;
        info!(
            "[PageViewState] update_view_size. w-h:{:?}-{:?}, zoom:{:?}, view:{:?}-{:?}",
            width, height, zoom, self.view_size.0, self.view_size.1
        );

        self.recalculate_layout();
    }
rust 复制代码
recalculate_layout就是判断方向是横向还是竖向的
match self.orientation {
            Orientation::Vertical => self.layout_vertical(),
            Orientation::Horizontal => self.layout_horizontal(),
        }

垂直布局

rust 复制代码
fn layout_vertical(&mut self) {
        let view_width = self.view_size.0;
        let scaled_width = view_width * self.zoom;
        let mut current_y = 0.0;

        for page in &mut self.pages {
            let page_width = page.info.get_width(self.crop == 1);
            let page_height = page.info.get_height(self.crop == 1);

            // 计算缩放比例
            let scale = scaled_width / page_width;
            let scaled_height = page_height * scale;

            // 更新页面
            let bounds = Rect::new(0.0, current_y, scaled_width, current_y + scaled_height);
            page.update(scaled_width, scaled_height, bounds);
            page.info.scale = scale;

            current_y += scaled_height;
        }

        debug!(
            "[PageViewState] layout_vertical.end:total_width:{:?}-total_height:{:?}",
            scaled_width, current_y
        );
        self.total_width = scaled_width;
        self.total_height = current_y;
    }

这个方式就是通过从页面0开始到结束,根据缩放,然后得到垂直的所有高度,与每一个page对应的bound,其实就是它的图片最终应该渲染在什么位置.只要每次缩放等影响属性变化,就要重新调整这些数据一次,可以想像为,所有的页面都有了占位的空间大小了.剩下的事就是滚动,偏移到这里的时候,把它对应的页面图片解码出来,然后显示.这就是所谓的布局了.

到这里未触发解码.

可见页的计算

rust 复制代码
pub fn update_visible_pages(&mut self) {
        self.visible_pages.clear();

        let (offset_x, offset_y) = self.view_offset;
        let (view_width, view_height) = self.view_size;

        // 计算预加载区域
        let preload_distance = match self.orientation {
            Orientation::Vertical => view_height * self.preload_screens,
            Orientation::Horizontal => view_width * self.preload_screens,
        };

        // 可见区域(包含预加载)
        let visible_rect = match self.orientation {
            Orientation::Vertical => Rect::new(
                -offset_x,
                -offset_y,
                -offset_x + view_width,
                -offset_y + view_height  + preload_distance,
            ),
            Orientation::Horizontal => Rect::new(
                -offset_x,
                -offset_y,
                -offset_x + view_width + preload_distance,
                -offset_y + view_height,
            ),
        };

        // 使用二分查找优化
        let first = self.find_first_visible(&visible_rect);
        let last = self.find_last_visible(&visible_rect);

        debug!("[PageViewState] update_visible_pages: first={}, last={}, total_pages={}", 
            first, last, self.pages.len());

        let mut render_pages = Vec::new();

        if first <= last && first < self.pages.len() {
            for i in first..=last.min(self.pages.len() - 1) {
                self.visible_pages.push(i);

                let page = &self.pages[i];
                let key = generate_thumbnail_key(page);
                
                if page.width > 0.0 && page.height > 0.0 {
                    // 先检查缓存中是否已有该页面
                    if self.cache.get_thumbnail(&key).is_none() {
                        debug!("[PageViewState] 需要解码: page={}, key={}", page.info.index, key);
                        
                        render_pages.push(RenderPage {
                            key,
                            page_info: page.info.clone(),
                            crop: self.crop,
                            priority: Priority::Thumbnail,
                        });
                    } else {
                        debug!("[PageViewState] 页面已在缓存中: page={}, key={}", page.info.index, key);
                    }
                }
            }
        }
        
        info!("[PageViewState] update_visible_pages完成: visible_pages={:?}", self.visible_pages);

        // 批量提交解码任务
        if !render_pages.is_empty() {
            debug!("[PageViewState] 批量提交 {} 个解码任务:", render_pages.len());
            self.decode_service.render_pages(render_pages);
        }
    }

这里的设计思路就是根据视图窗口的大小,目前滚动偏移,得到当前显示的页面与预加载的页面是哪些.然后组成一个列表,向解码服务发送请求.这里的列表会过滤已经解码过的数据.所以还有一个列表是当前可见页的页码,用于ui的显示处理.

偏移量,根据二分查找,可以快速定位到当前向上,或向下查找可见页,时间是logn,还算可以的,即使上千页的书,也不慢.

可以看到,对ui暴露的重要的几个方法.当ui上滚动,缩放,页面跳转,先触发偏移量,是否重新布局,然后计算可见页,最后refresh_view.就可以了

refresh_view已经涉及到了slint了.

到这里,发现,解码没有回调,前面的解码发送的数据到哪里去了.如何最后渲染到ui上?

ui显示

要显示,先要接收解码通过通道发送的数据.这里采用一个定时器去检测.因为通道接收是阻塞的

所以在main函数里面有一个定时器

rust 复制代码
let decode_timer = {
        let weak_app = app.as_weak();
        let state_clone = Rc::clone(&page_view_state);
        let timer = slint::Timer::default();
        let timer_count = Rc::new(RefCell::new(0));
        let timer_count_clone = Rc::clone(&timer_count);
        
        timer.start(
            slint::TimerMode::Repeated,
            std::time::Duration::from_millis(100),
            move || {
                let mut count = timer_count_clone.borrow_mut();
                *count += 1;
                if *count % 10 == 0 {
                    debug!("[Main] 定时器运行中... count={}", *count);
                }
                
                if let Some(app) = weak_app.upgrade() {
                    // 处理所有待处理的结果
                    let mut had_results = false;
                    let mut result_count = 0;
                    {
                        let mut state = state_clone.borrow_mut();
                        // 检查是否有结果(不消费)
                        while let Some(result) = state.decode_service.try_recv_result() {
                            had_results = true;
                            result_count += 1;
                            debug!("[Main] 收到解码结果: page={}, key={}, size={}x{}", 
                                result.page_info.index, result.key, result.image_width, result.image_height);
                            
                            // 将原始数据转换为Slint图像
                            let image = image::RgbaImage::from_raw(
                                result.image_width,
                                result.image_height,
                                result.image_data,
                            );

                            if let Some(rgba_image) = image {
                                let dynamic_image = image::DynamicImage::ImageRgba8(rgba_image);
                                let slint_image = convert_to_slint_image(&dynamic_image);
                                
                                // 更新缓存
                                state.cache.put_thumbnail(result.key.clone(), slint_image);
                                info!("[Main] 已更新缓存: key={}", result.key);
                                
                                // 更新链接
                                state.page_links
                                    .borrow_mut()
                                    .insert(result.page_info.index, result.links);
                            } else {
                                error!("[Main] 创建RgbaImage失败: page={}", result.page_info.index);
                            }
                        }
                    }
                    
                    if had_results {
                        debug!("[Main] 处理了 {} 个解码结果,刷新视图", result_count);
                        refresh_view(&app, &state_clone.borrow());
                    }
                }
            },
        );
        timer // 返回timer以保持其存活
    };

由于解码的数据不能转为slint可显示的数据再传出来,不符合trait,所以只能在ui接收到相关的数据后convert_to_slint_image转为slint可用的,这个转换耗时还能接受,所以并不太卡.

转换后,放到缓存中备用.这时未产生渲染.

渲染

rust 复制代码
fn refresh_view(app: &MainWindow, page_view_state: &PageViewState) {
    let state = page_view_state;
    if state.pages.is_empty() {
        debug!("[Main] No pages to refresh");
        return;
    }

    debug!("[Main] refresh_view: visible_pages={:?}", page_view_state.visible_pages);

    let rendered_pages = page_view_state.visible_pages
        .iter()
        .filter_map(|&idx| page_view_state.pages.get(idx))
        .map(|page| {
            // 尝试从缓存获取图像,如果不存在则使用默认图像
            let key = generate_thumbnail_key(page);
            let image = {
                if let Some(cached_image) = page_view_state.cache.get_thumbnail(&key) {
                    debug!("[Main] 从缓存获取图像: key={}, page={}", key, page.info.index);
                    cached_image.as_ref().clone()
                } else {
                    debug!("[Main] 缓存中没有图像,显示页码: key={}, page={}", key, page.info.index);
                    slint::Image::default()
                }
            };
            
            PageData {
                x: page.bounds.left,
                y: page.bounds.top,
                width: page.width,
                height: page.height,
                image,
                page_index: page.info.index as i32,
            }
        })
        .collect::<Vec<_>>();

    info!(
        "[Main] refresh_view {} page_models",
        rendered_pages.len()
    );
    let model = Rc::new(VecModel::from(rendered_pages));
    app.set_document_pages(ModelRc::from(model));
    app.set_page_count(page_view_state.pages.len() as i32);
    app.set_zoom(page_view_state.zoom);

    if let Some(first_visible) = page_view_state.get_first_visible_page() {
        app.set_current_page((first_visible + 1) as i32);  // UI expects 1-based page numbers
    }

    let (total_width, total_height) = (page_view_state.total_width, page_view_state.total_height);
    app.set_total_width(total_width);
    app.set_total_height(total_height);

    let (offset_x, offset_y) = (page_view_state.view_offset.0, page_view_state.view_offset.1);
    app.set_scroll_events_enabled(false);
    app.set_offset_x(offset_x);
    app.set_offset_y(offset_y);
    app.set_scroll_events_enabled(true);
    debug!(
        "[Main] refresh_view.offset: ({}, {}), total.w-h: ({}, {})",
        offset_x, offset_y, total_width, total_height
    );
}

这个方法就是在前面监听的各种事件完成后,然后再都要调用的方法.它就是根据state里面计算的可见页,去查询内存中接收到的数据,形成一个列表,然后发送给slint去显示.

in property <PageData> document-pages;这是在slint声明的的,对应这里的set_document_pages

PageData就是最终要显示的数据了.

rust 复制代码
DocumentView {
                    horizontal-stretch: 1;
                    pages: root.document-pages;
                    total-width: root.total-width;
                    total-height: root.total-height;
                    offset-x <=> root.offset-x;
                    offset-y <=> root.offset-y;
                    enable-scroll-events: root.scroll-events-enabled;
                    viewport-height <=> root.viewport-height;
                    viewport-changed(width, height) => { root.viewport-changed(width, height); }
                    scroll-changed(x, y) => { root.scroll-changed(x, y); }
                    page-down() => { root.page-down(); }
                    page-up() => { root.page-up(); }
                    page-clicked(x, y, page_index) => { root.page-clicked(x, y, page_index); }
                }

documentview

它里面包含了一个滚动的view,然后就是page的显示

rust 复制代码
ScrollView {
        width: root.width;
        height: root.height;
        viewport-width: max(root.total-width, self.width);
        viewport-height: max(root.total-height, self.height);
        viewport-x <=> root.offset-x;
        viewport-y <=> root.offset-y;
        mouse-drag-pan-enabled: true;

        content := Rectangle {
            width: max(root.total-width, scroller.visible-width);
            height: max(root.total-height, scroller.visible-height);
            background: #ffffff;
            clip: true;

            for page in pages: Rectangle {
                x: page.x * 1px;
                y: page.y * 1px;
                width: page.width * 1px;
                height: page.height * 1px;
                border-width: 1px;
                border-color: #d0d0d0;
                clip: true;
                //background: #f5f5f5;

                if page.image.width > 0 && page.image.height > 0: Image {
                    width: parent.width;
                    height: parent.height;
                    source: page.image;
                    image-fit: fill;
                }

                // 显示页码(如果没有图片)
                if !(page.image.width > 0 && page.image.height > 0): Text {
                    text: "Page " + (page.page_index + 1);
                    font-size: 24px;
                    color: #999999;
                    horizontal-alignment: center;
                    vertical-alignment: center;
                }

                TouchArea {
                    pointer-event(event) => {
                        if event.kind == PointerEventKind.down && event.button == PointerEventButton.left {
                            //debug("down.event", (self.mouse-x / 1px), (self.mouse-y / 1px), event);
                            root.page-clicked(self.mouse-x / 1px, self.mouse-y/ 1px, page.page_index);
                        }
                    }
                }
            }
        }

        scrolled => {
            if (root.enable-scroll-events) {
                root.desired_offset_x = scroller.viewport-x;
                root.desired_offset_y = scroller.viewport-y;
                if (!scroll_timer.running) {
                    scroll_timer.running = true;
                } else {
                    scroll_timer.restart();
                }
            }
        }
    }

重要的应该是x: page.x * 1px;y: page.y * 1px; 这样可以在它具体显示的时候知道偏移量.具体slint如何处理这些东西的,要看它的文档了.

对每一页设置了一个TouchArea,这样可以处理链接等的点击事件了.

这里省事的地方是滚动这些slint处理好了,不像compose要自己处理所有的手势.省不少力.当然slint用ai助手也写不明白,字体,鼠标事件它总是找不到正确的方法.

总结

整个完成下来的感觉就是快.

写代码少,完成的功能多.

运行速度快.

解码服务只关心解码,完成后通道发出去后不管了.

状态管理只管页面可见性计算,布局,缓存这些,剩下怎么显示不管了.

slint只管数据来了我显示,其它通过各种事件,通知给main中的rust监听代码去触发偏移,可见性计算.

这几部分分的真开.你想耦合都不太容易.

做compose阅读器的时候,很容易把这几部分的东西耦合在一起.我想主要是由于slint而被迫拆分的,如果是其它的ui框架,纯rust实现,代码又耦合在一起了.另一部分也是由于rust.

还有不少功能没有实现.

比如分块布局,如果没有这个,超大的tiff图片就无法显示,虽然tiff解码器有了,可以快速解码,但依然无法一次性渲染出整块图片的.如果再放大就会内存爆炸.所以要上大的tiff,就要先完成分块解码的逻辑,分块就会影响布局和解码,可见性的计算等等.在compose里面是实现了的.只是现在没有迁移过来.

tts,jvm的实现是通过调用系统的命令,mac/windows/linux都有各自的朗读功能,写一个适配命令就可以了,虽然效果没有android那么好,还算可用.

切边没有实现,在电脑上看文档切边不是那么迫切,手机上是比较迫切的,因为屏幕小.

webdav也没那么迫切的需求,尤其epub目前还做不到页码同步,因为layout的高宽不一样,会导致整个文档的页数变化.我又不想用相同的高宽.

后续准备实现的更大优先级的是工具,tts.然后才是图片查看器

文档加密/解码,还有文档的拆分,合并,这些mupdf已经有了,compose也实现了.然后就是mobi/azw3转为epub,这个需要编译库,rust再看看有没有现成的.

最后就是等mupdf-rs的作者搞写了字体,这样就可以打开features,显示epub这些内容了.然后可以放弃pdf expert了,这是收费应用,贵的很.

相关推荐
~烈20 小时前
Acrobat Pro DC 便携精简版 PDF 专业处理工具安装教程
pdf·acrobat pro dc·pdf 专业处理工具
我不是QI21 小时前
Master PDF Editor 逆向复现
汇编·安全·pdf
蓝创工坊Blue Foundry1 天前
多个同模板 PDF,怎样批量提取同一类字段到 Excel
运维·数据库·pdf·自动化·ocr·excel
asdzx672 天前
Python PDF 拆分实战指南:单页拆分与按需页码范围拆分
开发语言·python·pdf
慧都小妮子3 天前
Aspose.CAD for .NET 26.6 更新发布
pdf·.net·3d渲染·cad·pdf导出·ifc编辑
蝉蜕日记3 天前
AccumuPDF高级版 v2.57 | 视图文转换PDF,合并分割压缩
android·智能手机·pdf·生活·软件需求
蓝创工坊Blue Foundry3 天前
PDF 批量提取指定内容到 Excel:按字段整理多个 PDF 的方法
pdf·ocr·excel·文心一言·paddlepaddle·paddle
Am-Chestnuts3 天前
AI长回答批量导出PDF与长图:多轮内容整理和免费Markdown备份
人工智能·pdf·aigc
zyplayer-doc3 天前
研发接口文档怎么长期维护:zyplayer-doc把API、Markdown和变更记录放进同一个知识库
大数据·数据库·人工智能·笔记·pdf·ocr
蜡台3 天前
uniapp pdf文件预览组件
pdf·uni-app·合同·pdfh5