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 }