git.lucas.co / cce-preview
document and image viewer
git clone https://git.lucas.co/cce-preview.git

src/doc.rs (11.1K)

  1 //! Document model + background page rasterization for cce-preview.
  2 //!
  3 //! A document is a list of pages with known sizes: PDFs report points via
  4 //! `pdfinfo` and rasterize per page through `pdftoppm` (poppler), raster
  5 //! images are single-page documents sized in pixels. Workers decode or
  6 //! rasterize off-thread, upload RGBA via `cce_ui::vk::upload_rgba` (the
  7 //! upload queue is thread-safe), then notify the app over the calloop
  8 //! channel so the engine wakes and repaints.
  9 
 10 use std::collections::HashMap;
 11 use std::path::{Path, PathBuf};
 12 use std::process::Command;
 13 use std::sync::{mpsc, Arc, Mutex};
 14 
 15 use crate::Message;
 16 
 17 /// Extensions the `image` crate is built to decode (keep in sync with the
 18 /// feature list in Cargo.toml).
 19 pub const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "ico"];
 20 
 21 /// GPU pages kept resident. The cce-ui image registry hard-caps at 256
 22 /// images total, so leave generous headroom.
 23 const MAX_GPU_PAGES: usize = 24;
 24 /// Largest bitmap edge we'll upload; bigger sources are downscaled (images)
 25 /// or rendered at a capped DPI (PDF pages).
 26 const MAX_DIM: u32 = 8192;
 27 const RENDER_THREADS: usize = 2;
 28 
 29 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 30 pub enum Kind {
 31     Image,
 32     Pdf,
 33 }
 34 
 35 /// Page size in document units: points for PDFs, pixels for images.
 36 #[derive(Debug, Clone, Copy)]
 37 pub struct PageSize {
 38     pub w: f64,
 39     pub h: f64,
 40 }
 41 
 42 pub struct Document {
 43     pub path: PathBuf,
 44     pub kind: Kind,
 45     pub pages: Vec<PageSize>,
 46 }
 47 
 48 impl Document {
 49     pub fn load(path: &Path) -> Result<Self, String> {
 50         let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
 51         match ext.as_deref() {
 52             Some("pdf") => Self::load_pdf(path),
 53             Some(e) if IMAGE_EXTS.contains(&e) => Self::load_image(path),
 54             _ => Err("unsupported file type".to_string()),
 55         }
 56     }
 57 
 58     fn load_image(path: &Path) -> Result<Self, String> {
 59         let (w, h) = image::image_dimensions(path).map_err(|e| e.to_string())?;
 60         Ok(Self {
 61             path: path.to_path_buf(),
 62             kind: Kind::Image,
 63             pages: vec![PageSize { w: w as f64, h: h as f64 }],
 64         })
 65     }
 66 
 67     /// Page count from `pdfinfo`, then per-page sizes from a second ranged
 68     /// call. `pdfinfo` reports MediaBox dimensions with a separate `rot`
 69     /// field, while `pdftoppm` bakes /Rotate into its output — so swap
 70     /// width/height here for 90°/270° pages to keep layout and pixels agreed.
 71     fn load_pdf(path: &Path) -> Result<Self, String> {
 72         let count_out = pdfinfo(path, &[])?;
 73         let count: usize = count_out
 74             .lines()
 75             .find_map(|l| l.strip_prefix("Pages:"))
 76             .and_then(|v| v.trim().parse().ok())
 77             .ok_or("pdfinfo: no page count")?;
 78         if count == 0 {
 79             return Err("empty PDF".to_string());
 80         }
 81         let sizes_out = pdfinfo(path, &["-f", "1", "-l", &count.to_string()])?;
 82         let mut sizes: Vec<PageSize> = Vec::with_capacity(count);
 83         let mut rots: Vec<i32> = Vec::with_capacity(count);
 84         for line in sizes_out.lines() {
 85             let Some(rest) = line.strip_prefix("Page ") else { continue };
 86             let Some((_, field)) = rest.trim_start().split_once(' ') else { continue };
 87             if let Some(v) = field.trim_start().strip_prefix("size:") {
 88                 // "595.276 x 841.89 pts (A4)"
 89                 let mut it = v.trim().split_whitespace();
 90                 let w: f64 = it.next().and_then(|s| s.parse().ok()).ok_or("pdfinfo: bad size")?;
 91                 let h: f64 = it.nth(1).and_then(|s| s.parse().ok()).ok_or("pdfinfo: bad size")?;
 92                 sizes.push(PageSize { w, h });
 93             } else if let Some(v) = field.trim_start().strip_prefix("rot:") {
 94                 rots.push(v.trim().parse().unwrap_or(0));
 95             }
 96         }
 97         if sizes.len() != count {
 98             return Err(format!("pdfinfo: {} sizes for {count} pages", sizes.len()));
 99         }
100         for (s, rot) in sizes.iter_mut().zip(rots) {
101             if rot == 90 || rot == 270 {
102                 std::mem::swap(&mut s.w, &mut s.h);
103             }
104         }
105         Ok(Self { path: path.to_path_buf(), kind: Kind::Pdf, pages: sizes })
106     }
107 }
108 
109 fn pdfinfo(path: &Path, args: &[&str]) -> Result<String, String> {
110     let out = Command::new("pdfinfo")
111         .args(args)
112         .arg(path)
113         .output()
114         .map_err(|e| format!("pdfinfo: {e}"))?;
115     if !out.status.success() {
116         return Err(format!("pdfinfo: {}", String::from_utf8_lossy(&out.stderr).trim()));
117     }
118     Ok(String::from_utf8_lossy(&out.stdout).into_owned())
119 }
120 
121 /// A rendered page as delivered by a worker.
122 #[derive(Debug, Clone, Copy)]
123 pub struct Rendered {
124     pub image: u32,
125     pub dpi: u32,
126 }
127 
128 struct Job {
129     generation: u64,
130     page: usize,
131     dpi: u32,
132     path: PathBuf,
133     kind: Kind,
134     size: PageSize,
135     /// Extra user rotation in quarter turns cw, applied to the pixels.
136     quarter_turns: u8,
137 }
138 
139 enum PageState {
140     Pending,
141     Ready { r: Rendered, refreshing: bool, last_used: u64 },
142     Failed,
143 }
144 
145 /// Per-document GPU page cache: lazy render requests, DPI upgrades, LRU
146 /// eviction. `reset()` bumps the generation so late results from a previous
147 /// document/rotation are freed on arrival instead of displayed.
148 pub struct PageStore {
149     states: HashMap<usize, PageState>,
150     queue: mpsc::Sender<Job>,
151     generation: u64,
152     frame: u64,
153 }
154 
155 impl PageStore {
156     pub fn new(notify: calloop::channel::Sender<Message>) -> Self {
157         let (queue, rx) = mpsc::channel::<Job>();
158         let rx = Arc::new(Mutex::new(rx));
159         for _ in 0..RENDER_THREADS {
160             let rx = Arc::clone(&rx);
161             let notify = notify.clone();
162             std::thread::spawn(move || worker(rx, notify));
163         }
164         Self { states: HashMap::new(), queue, generation: 0, frame: 0 }
165     }
166 
167     pub fn begin_frame(&mut self) {
168         self.frame += 1;
169     }
170 
171     pub fn reset(&mut self) {
172         for (_, state) in self.states.drain() {
173             if let PageState::Ready { r, .. } = state {
174                 cce_ui::vk::free_image(r.image);
175             }
176         }
177         self.generation += 1;
178     }
179 
180     /// The page's GPU image if resident (marks it used, queues a DPI upgrade
181     /// when the resident render is stale); otherwise queues a render (once)
182     /// and returns None.
183     pub fn ensure(&mut self, doc: &Document, quarter_turns: u8, page: usize, want_dpi: u32) -> Option<Rendered> {
184         let job = |dpi| Job {
185             generation: self.generation,
186             page,
187             dpi,
188             path: doc.path.clone(),
189             kind: doc.kind,
190             size: doc.pages[page],
191             quarter_turns,
192         };
193         match self.states.get_mut(&page) {
194             Some(PageState::Ready { r, refreshing, last_used }) => {
195                 *last_used = self.frame;
196                 if r.dpi != want_dpi && doc.kind == Kind::Pdf && !*refreshing {
197                     *refreshing = true;
198                     let _ = self.queue.send(job(want_dpi));
199                 }
200                 Some(*r)
201             }
202             Some(_) => None,
203             None => {
204                 self.states.insert(page, PageState::Pending);
205                 let _ = self.queue.send(job(want_dpi));
206                 None
207             }
208         }
209     }
210 
211     pub fn complete(&mut self, generation: u64, page: usize, result: Option<Rendered>) {
212         if generation != self.generation {
213             if let Some(r) = result {
214                 cce_ui::vk::free_image(r.image);
215             }
216             return;
217         }
218         let state = match result {
219             Some(r) => PageState::Ready { r, refreshing: false, last_used: self.frame },
220             None => PageState::Failed,
221         };
222         if let Some(PageState::Ready { r, .. }) = self.states.insert(page, state) {
223             cce_ui::vk::free_image(r.image);
224         }
225         self.evict();
226     }
227 
228     /// Free the least-recently-used pages once over budget; pages touched
229     /// this frame are never evicted.
230     fn evict(&mut self) {
231         let resident = self.states.values().filter(|s| matches!(s, PageState::Ready { .. })).count();
232         if resident <= MAX_GPU_PAGES {
233             return;
234         }
235         let mut ready: Vec<(usize, u64)> = self
236             .states
237             .iter()
238             .filter_map(|(p, s)| match s {
239                 PageState::Ready { last_used, .. } if *last_used < self.frame => Some((*p, *last_used)),
240                 _ => None,
241             })
242             .collect();
243         ready.sort_by_key(|&(_, used)| used);
244         for (page, _) in ready.into_iter().take(resident - MAX_GPU_PAGES) {
245             if let Some(PageState::Ready { r, .. }) = self.states.remove(&page) {
246                 cce_ui::vk::free_image(r.image);
247             }
248         }
249     }
250 }
251 
252 fn worker(rx: Arc<Mutex<mpsc::Receiver<Job>>>, notify: calloop::channel::Sender<Message>) {
253     loop {
254         let job = match rx.lock().unwrap().recv() {
255             Ok(j) => j,
256             Err(_) => return,
257         };
258         let result = render(&job)
259             .map_err(|e| log::warn!("{}: page {}: {e}", job.path.display(), job.page + 1))
260             .ok();
261         let msg = Message::Page { generation: job.generation, page: job.page, result };
262         if notify.send(msg).is_err() {
263             return;
264         }
265     }
266 }
267 
268 fn render(job: &Job) -> Result<Rendered, String> {
269     let mut rgba = match job.kind {
270         Kind::Image => {
271             let img = image::open(&job.path).map_err(|e| e.to_string())?;
272             let mut rgba = img.to_rgba8();
273             let (w, h) = rgba.dimensions();
274             if w.max(h) > MAX_DIM {
275                 let s = MAX_DIM as f64 / w.max(h) as f64;
276                 let (nw, nh) = (((w as f64 * s) as u32).max(1), ((h as f64 * s) as u32).max(1));
277                 rgba = image::imageops::resize(&rgba, nw, nh, image::imageops::FilterType::Triangle);
278             }
279             rgba
280         }
281         Kind::Pdf => {
282             // Cap the DPI so the page bitmap stays under MAX_DIM on its
283             // longer edge (page size is in points, 72/inch).
284             let max_pts = job.size.w.max(job.size.h).max(1.0);
285             let dpi = (job.dpi as f64).min(MAX_DIM as f64 * 72.0 / max_pts).max(18.0) as u32;
286             let page = (job.page + 1).to_string();
287             // No output root: poppler's pdftoppm writes the PNG to stdout.
288             let out = Command::new("pdftoppm")
289                 .args(["-png", "-r", &dpi.to_string(), "-f", &page, "-l", &page])
290                 .arg(&job.path)
291                 .output()
292                 .map_err(|e| format!("pdftoppm: {e}"))?;
293             if !out.status.success() || out.stdout.is_empty() {
294                 return Err(format!("pdftoppm: {}", String::from_utf8_lossy(&out.stderr).trim()));
295             }
296             image::load_from_memory(&out.stdout).map_err(|e| e.to_string())?.to_rgba8()
297         }
298     };
299     match job.quarter_turns % 4 {
300         1 => rgba = image::imageops::rotate90(&rgba),
301         2 => rgba = image::imageops::rotate180(&rgba),
302         3 => rgba = image::imageops::rotate270(&rgba),
303         _ => {}
304     }
305     let (w, h) = rgba.dimensions();
306     let image = cce_ui::vk::upload_rgba(rgba.into_raw(), w, h);
307     Ok(Rendered { image, dpi: job.dpi })
308 }