用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了,这是收费应用,贵的很.

相关推荐
坚定信念,勇往无前1 小时前
vue3图片,pdf,word,excel,ppt多格式文件预览组件Vue Doc Viewers Plus
pdf·word·excel
拓端研究室18 小时前
2025医疗健康行业革新报告:AI赋能、国际化|附170+份报告PDF、数据、可视化模板汇总下载
人工智能·pdf
小年糕是糕手1 天前
【C++】类和对象(六) -- 友元、内部类、匿名对象、对象拷贝时的编译器优化
开发语言·c++·算法·pdf·github·排序算法
libolei1 天前
压缩 pdf 文件大小 完全免费
pdf·pdf压缩
一只小羊啊1 天前
Vue + Android WebView 实现大文件 PDF 预览完整解决方案
android·vue.js·pdf·webview
梅如你1 天前
《从零开始构建智能体》PDF教程分享
pdf
团圆吧1 天前
md2pdf.py:高效 Markdown 转 PDF 全能工具
python·pdf·tensorflow
qq_296544651 天前
在怎么编辑PDF?专业级pdf转换教程,PDF在线编辑,Word转PDF使用方法
microsoft·pdf·word
拓端研究室2 天前
专题:2025AI产业应用与投资趋势报告:技术选型、行业落地与效益洞察|附900+份报告PDF、数据、可视化模板汇总下载
pdf