1use std::sync::Arc;
47
48use crate::{
49 djvu_document::DjVuPage,
50 djvu_render::{self, RenderError, RenderOptions},
51 pixmap::{GrayPixmap, Pixmap},
52};
53
54#[derive(Debug, thiserror::Error)]
58pub enum AsyncRenderError {
59 #[error("render error: {0}")]
61 Render(#[from] RenderError),
62
63 #[error("spawn_blocking join error: {0}")]
65 Join(String),
66}
67
68pub 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
104pub 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
120pub fn render_progressive_stream(
155 page: &DjVuPage,
156 opts: RenderOptions,
157) -> impl futures_core::Stream<Item = Result<Pixmap, AsyncRenderError>> {
158 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#[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 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}