Skip to main content

djvu_rs/
djvu_async.rs

1//! Async render surface for [`DjVuPage`] — phase 5 extension.
2//!
3//! Feature-gated: `--features async` (adds `tokio` as a dependency).
4//!
5//! All rendering is delegated to [`tokio::task::spawn_blocking`]: the CPU-bound
6//! IW44/JB2 decode work runs on the blocking thread pool and never blocks the
7//! async runtime thread.
8//!
9//! [`DjVuPage`] implements [`Clone`], so the page is cloned into the blocking
10//! closure with no unsafe code and no thread management by the caller.
11//!
12//! ## Key public functions
13//!
14//! - [`render_pixmap_async`] — async wrapper around [`djvu_render::render_pixmap`]
15//! - [`render_gray8_async`] — async wrapper around [`djvu_render::render_gray8`]
16//! - [`render_progressive_stream`] — streaming progressive render yielding one frame per BG44 chunk
17//!
18//! ## Example: concurrent multi-page rendering
19//!
20//! ```no_run
21//! use djvu_rs::djvu_document::DjVuDocument;
22//! use djvu_rs::djvu_render::RenderOptions;
23//! use djvu_rs::djvu_async::render_pixmap_async;
24//!
25//! #[tokio::main]
26//! async fn main() {
27//!     let data = std::fs::read("document.djvu").unwrap();
28//!     let doc = std::sync::Arc::new(DjVuDocument::parse(&data).unwrap());
29//!
30//!     let futures: Vec<_> = (0..doc.page_count())
31//!         .filter_map(|i| doc.page(i).ok())
32//!         .map(|page| {
33//!             let page = page.clone();
34//!             let opts = RenderOptions { width: 800, height: 600, ..Default::default() };
35//!             tokio::spawn(async move { render_pixmap_async(&page, opts).await })
36//!         })
37//!         .collect();
38//!
39//!     for handle in futures {
40//!         let pixmap = handle.await.unwrap().unwrap();
41//!         println!("{}×{}", pixmap.width, pixmap.height);
42//!     }
43//! }
44//! ```
45
46use std::sync::Arc;
47
48use crate::{
49    djvu_document::DjVuPage,
50    djvu_render::{self, RenderError, RenderOptions},
51    pixmap::{GrayPixmap, Pixmap},
52};
53
54// ── Error type ────────────────────────────────────────────────────────────────
55
56/// Errors from async rendering.
57#[derive(Debug, thiserror::Error)]
58pub enum AsyncRenderError {
59    /// The underlying render failed.
60    #[error("render error: {0}")]
61    Render(#[from] RenderError),
62
63    /// The blocking task was cancelled or panicked.
64    #[error("spawn_blocking join error: {0}")]
65    Join(String),
66}
67
68// ── Async render functions ────────────────────────────────────────────────────
69
70/// Render `page` to an RGBA [`Pixmap`] asynchronously.
71///
72/// Clones the page and delegates to [`djvu_render::render_pixmap`] via
73/// [`tokio::task::spawn_blocking`]. The render runs on the blocking thread
74/// pool and does not block the async runtime.
75///
76/// # Example
77///
78/// ```no_run
79/// # async fn example() {
80/// use djvu_rs::djvu_document::DjVuDocument;
81/// use djvu_rs::djvu_render::RenderOptions;
82/// use djvu_rs::djvu_async::render_pixmap_async;
83///
84/// let data = std::fs::read("file.djvu").unwrap();
85/// let doc = DjVuDocument::parse(&data).unwrap();
86/// let page = doc.page(0).unwrap();
87/// let opts = RenderOptions { width: 400, height: 300, ..Default::default() };
88/// let pixmap = render_pixmap_async(page, opts).await.unwrap();
89/// println!("{}×{}", pixmap.width, pixmap.height);
90/// # }
91/// ```
92pub async fn render_pixmap_async(
93    page: &DjVuPage,
94    opts: RenderOptions,
95) -> Result<Pixmap, AsyncRenderError> {
96    let page = Arc::new(page.clone());
97    tokio::task::spawn_blocking(move || {
98        djvu_render::render_pixmap(&page, &opts).map_err(AsyncRenderError::Render)
99    })
100    .await
101    .map_err(|e| AsyncRenderError::Join(e.to_string()))?
102}
103
104/// Render `page` to an 8-bit grayscale [`GrayPixmap`] asynchronously.
105///
106/// Clones the page and delegates to [`djvu_render::render_gray8`] via
107/// [`tokio::task::spawn_blocking`].
108pub async fn render_gray8_async(
109    page: &DjVuPage,
110    opts: RenderOptions,
111) -> Result<GrayPixmap, AsyncRenderError> {
112    let page = Arc::new(page.clone());
113    tokio::task::spawn_blocking(move || {
114        djvu_render::render_gray8(&page, &opts).map_err(AsyncRenderError::Render)
115    })
116    .await
117    .map_err(|e| AsyncRenderError::Join(e.to_string()))?
118}
119
120/// Render a `DjVuPage` as a lazy progressive stream of [`Pixmap`] frames.
121///
122/// Yields one frame per BG44 wavelet refinement chunk: the first frame is the
123/// coarsest (fastest to produce), and each subsequent frame adds detail. The
124/// final frame is equivalent to [`render_pixmap`][djvu_render::render_pixmap].
125///
126/// If the page has no BG44 chunks (bilevel JB2-only pages), exactly one frame
127/// is yielded via [`render_pixmap`][djvu_render::render_pixmap].
128///
129/// Each frame is produced via [`tokio::task::spawn_blocking`] just before it is
130/// yielded, so the stream never blocks the async runtime thread.
131///
132/// # Example
133///
134/// ```no_run
135/// # async fn example() {
136/// use djvu_rs::djvu_document::DjVuDocument;
137/// use djvu_rs::djvu_render::RenderOptions;
138/// use djvu_rs::djvu_async::render_progressive_stream;
139/// use futures::StreamExt;
140///
141/// let data = std::fs::read("file.djvu").unwrap();
142/// let doc = DjVuDocument::parse(&data).unwrap();
143/// let page = doc.page(0).unwrap();
144/// let opts = RenderOptions { width: 800, height: 600, ..Default::default() };
145///
146/// let stream = render_progressive_stream(page, opts);
147/// futures::pin_mut!(stream);
148/// while let Some(pixmap) = stream.next().await {
149///     let pixmap = pixmap.unwrap();
150///     println!("{}×{}", pixmap.width, pixmap.height);
151/// }
152/// # }
153/// ```
154pub fn render_progressive_stream(
155    page: &DjVuPage,
156    opts: RenderOptions,
157) -> impl futures_core::Stream<Item = Result<Pixmap, AsyncRenderError>> {
158    // Single clone wrapped in Arc — all spawn_blocking closures share
159    // this one allocation instead of cloning the full page each time.
160    let page = Arc::new(page.clone());
161    let n_chunks = page.bg44_chunks().len();
162
163    async_stream::stream! {
164        if n_chunks == 0 {
165            let page = Arc::clone(&page);
166            let opts = opts.clone();
167            let result = tokio::task::spawn_blocking(move || {
168                djvu_render::render_pixmap(&page, &opts).map_err(AsyncRenderError::Render)
169            })
170            .await
171            .map_err(|e| AsyncRenderError::Join(e.to_string()));
172            yield result.and_then(|r| r);
173        } else {
174            for chunk_n in 0..n_chunks {
175                let page = Arc::clone(&page);
176                let opts = opts.clone();
177                let result = tokio::task::spawn_blocking(move || {
178                    djvu_render::render_progressive(&page, &opts, chunk_n)
179                        .map_err(AsyncRenderError::Render)
180                })
181                .await
182                .map_err(|e| AsyncRenderError::Join(e.to_string()));
183                yield result.and_then(|r| r);
184            }
185        }
186    }
187}
188
189// ── Tests ─────────────────────────────────────────────────────────────────────
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::djvu_document::DjVuDocument;
195
196    fn assets_path() -> std::path::PathBuf {
197        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
198            .join("references/djvujs/library/assets")
199    }
200
201    fn load_doc(name: &str) -> DjVuDocument {
202        let data =
203            std::fs::read(assets_path().join(name)).unwrap_or_else(|_| panic!("{name} must exist"));
204        DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("{e}"))
205    }
206
207    /// `render_pixmap_async` returns a pixmap with correct dimensions.
208    #[tokio::test]
209    async fn render_pixmap_async_correct_dims() {
210        let doc = load_doc("chicken.djvu");
211        let page = doc.page(0).unwrap();
212        let pw = page.width() as u32;
213        let ph = page.height() as u32;
214
215        let opts = RenderOptions {
216            width: pw,
217            height: ph,
218            ..Default::default()
219        };
220        let pm = render_pixmap_async(page, opts)
221            .await
222            .expect("async render must succeed");
223        assert_eq!(pm.width, pw);
224        assert_eq!(pm.height, ph);
225    }
226
227    /// `render_gray8_async` returns a grayscale pixmap with the right size.
228    #[tokio::test]
229    async fn render_gray8_async_correct_dims() {
230        let doc = load_doc("chicken.djvu");
231        let page = doc.page(0).unwrap();
232        let pw = page.width() as u32;
233        let ph = page.height() as u32;
234
235        let opts = RenderOptions {
236            width: pw,
237            height: ph,
238            ..Default::default()
239        };
240        let gm = render_gray8_async(page, opts)
241            .await
242            .expect("async gray render must succeed");
243        assert_eq!(gm.width, pw);
244        assert_eq!(gm.height, ph);
245        assert_eq!(gm.data.len(), (pw * ph) as usize);
246    }
247
248    /// Async and sync renders produce identical results.
249    #[tokio::test]
250    async fn async_matches_sync() {
251        let doc = load_doc("chicken.djvu");
252        let page = doc.page(0).unwrap();
253        let pw = page.width() as u32;
254        let ph = page.height() as u32;
255
256        let opts = RenderOptions {
257            width: pw,
258            height: ph,
259            ..Default::default()
260        };
261        let sync_pm = djvu_render::render_pixmap(page, &opts).expect("sync render must succeed");
262        let async_pm = render_pixmap_async(page, opts.clone())
263            .await
264            .expect("async render must succeed");
265
266        assert_eq!(
267            sync_pm.data, async_pm.data,
268            "async and sync renders must match"
269        );
270    }
271
272    /// Concurrent rendering of multiple instances of the same page succeeds.
273    #[tokio::test]
274    async fn concurrent_render_multiple_tasks() {
275        let doc = load_doc("chicken.djvu");
276        let page = doc.page(0).unwrap();
277        let pw = page.width() as u32;
278        let ph = page.height() as u32;
279        let opts = RenderOptions {
280            width: pw / 2,
281            height: ph / 2,
282            scale: 0.5,
283            ..Default::default()
284        };
285
286        // Spawn 4 concurrent renders of the same page.
287        let handles: Vec<_> = (0..4)
288            .map(|_| {
289                let page_clone = page.clone();
290                let opts_clone = opts.clone();
291                tokio::spawn(async move { render_pixmap_async(&page_clone, opts_clone).await })
292            })
293            .collect();
294
295        for handle in handles {
296            let pm = handle
297                .await
298                .expect("task must not panic")
299                .expect("render must succeed");
300            assert_eq!(pm.width, pw / 2);
301            assert_eq!(pm.height, ph / 2);
302        }
303    }
304
305    /// `AsyncRenderError::Render` wraps `RenderError`.
306    #[test]
307    fn async_render_error_display() {
308        let err = AsyncRenderError::Render(crate::djvu_render::RenderError::InvalidDimensions {
309            width: 0,
310            height: 0,
311        });
312        let s = err.to_string();
313        assert!(
314            s.contains("render error"),
315            "error must mention 'render error'"
316        );
317    }
318
319    // ── render_progressive_stream tests ──────────────────────────────────────
320
321    /// Last frame from the progressive stream matches `render_pixmap`.
322    #[tokio::test]
323    async fn progressive_stream_last_frame_matches_pixmap() {
324        use futures::StreamExt;
325        let doc = load_doc("chicken.djvu");
326        let page = doc.page(0).unwrap();
327        let opts = RenderOptions {
328            width: 100,
329            height: 80,
330            ..Default::default()
331        };
332
333        let stream = render_progressive_stream(page, opts.clone());
334        futures::pin_mut!(stream);
335
336        let mut frames: Vec<Pixmap> = Vec::new();
337        while let Some(result) = stream.next().await {
338            frames.push(result.expect("frame should succeed"));
339        }
340
341        assert!(!frames.is_empty(), "stream must yield at least one frame");
342
343        let expected = djvu_render::render_pixmap(page, &opts).expect("render_pixmap must succeed");
344        assert_eq!(
345            frames.last().unwrap().data,
346            expected.data,
347            "last frame must match render_pixmap"
348        );
349    }
350
351    /// Each successive frame has the same dimensions.
352    #[tokio::test]
353    async fn progressive_stream_consistent_dimensions() {
354        use futures::StreamExt;
355        let doc = load_doc("chicken.djvu");
356        let page = doc.page(0).unwrap();
357        let n_chunks = page.bg44_chunks().len();
358        let opts = RenderOptions {
359            width: 100,
360            height: 80,
361            ..Default::default()
362        };
363
364        let stream = render_progressive_stream(page, opts);
365        futures::pin_mut!(stream);
366
367        let mut count = 0usize;
368        while let Some(result) = stream.next().await {
369            let frame = result.expect("frame should succeed");
370            assert_eq!(frame.width, 100);
371            assert_eq!(frame.height, 80);
372            count += 1;
373        }
374
375        let expected_count = if n_chunks == 0 { 1 } else { n_chunks };
376        assert_eq!(
377            count, expected_count,
378            "frame count must equal BG44 chunk count"
379        );
380    }
381
382    /// A JB2-only page (no BG44 chunks) yields exactly one frame.
383    #[tokio::test]
384    async fn progressive_stream_jb2_only_yields_one_frame() {
385        use futures::StreamExt;
386        let doc = load_doc("boy_jb2.djvu");
387        let page = doc.page(0).unwrap();
388        if !page.bg44_chunks().is_empty() {
389            // Page is not JB2-only; skip
390            return;
391        }
392        let opts = RenderOptions {
393            width: 80,
394            height: 60,
395            ..Default::default()
396        };
397
398        let stream = render_progressive_stream(page, opts);
399        futures::pin_mut!(stream);
400
401        let mut count = 0;
402        while let Some(result) = stream.next().await {
403            result.expect("frame should succeed");
404            count += 1;
405        }
406        assert_eq!(count, 1, "JB2-only page must yield exactly one frame");
407    }
408}