1use std::{collections::BTreeMap, ops::Range, sync::Arc};
44
45use tokio::{
46 io::{AsyncRead, AsyncReadExt, AsyncSeek, AsyncSeekExt},
47 sync::{Mutex, OnceCell},
48};
49
50use crate::{
51 dirm::{DirmComponentKind, DirmPayload},
52 djvu_document::{DjVuDocument, DjVuPage, DocError, SharedDict},
53 djvu_render::{self, RenderError, RenderOptions},
54 djvu_tile::{TileCancelToken, TileError, TileRenderControls},
55 error::IffError,
56 iff::{MAGIC, parse_form},
57 pixmap::Pixmap,
58};
59
60#[derive(Debug, thiserror::Error)]
64#[non_exhaustive]
65pub enum AsyncRenderError {
66 #[error("render error: {0}")]
68 Render(#[from] RenderError),
69
70 #[error("spawn_blocking join error: {0}")]
72 Join(String),
73}
74
75#[derive(Debug, thiserror::Error)]
77#[non_exhaustive]
78pub enum AsyncTileError {
79 #[error("tile error: {0}")]
82 Tile(#[from] TileError),
83
84 #[error("spawn_blocking join error: {0}")]
86 Join(String),
87}
88
89#[derive(Debug, thiserror::Error)]
93#[non_exhaustive]
94pub enum AsyncLazyError {
95 #[error("I/O error: {0}")]
97 Io(#[from] std::io::Error),
98
99 #[error("parse error: {0}")]
101 Parse(#[from] DocError),
102
103 #[error("IFF error: {0}")]
105 Iff(#[from] IffError),
106
107 #[error("page index {index} is out of range (document has {count} pages)")]
109 PageOutOfRange { index: usize, count: usize },
110
111 #[error("unsupported lazy document shape: {0}")]
113 Unsupported(&'static str),
114}
115
116#[derive(Debug, Clone)]
119struct LazyDirmEntry {
120 comp_type: DirmComponentKind,
121 id: String,
122 offset: u32,
123 size: u32,
126}
127
128pub struct LazyDocument<R> {
143 reader: Arc<Mutex<R>>,
144 pages: Vec<LazyPageIndex>,
145 shared: BTreeMap<String, LazyComponentIndex>,
146 cache: Vec<OnceCell<Arc<DjVuPage>>>,
147 shared_cache: BTreeMap<String, OnceCell<Arc<SharedDict>>>,
148}
149
150#[derive(Debug, Clone)]
151struct LazyPageIndex {
152 range: Range<u64>,
153}
154
155#[derive(Debug, Clone)]
156struct LazyComponentIndex {
157 range: Range<u64>,
158}
159
160impl<R> LazyDocument<R>
161where
162 R: AsyncRead + AsyncSeek + Unpin + 'static,
163{
164 pub async fn from_async_reader_lazy(mut reader: R) -> Result<Self, AsyncLazyError> {
166 let file_len = reader.seek(std::io::SeekFrom::End(0)).await?;
167 reader.seek(std::io::SeekFrom::Start(0)).await?;
168
169 let mut head = [0u8; 16];
170 reader.read_exact(&mut head).await?;
171 if &head[..4] != b"AT&T" || &head[4..8] != b"FORM" {
172 return Err(AsyncLazyError::Unsupported("not an AT&T FORM document"));
173 }
174
175 let form_type = &head[12..16];
176 let (pages, shared) = if form_type == b"DJVU" {
177 (vec![LazyPageIndex { range: 0..file_len }], BTreeMap::new())
178 } else if form_type == b"DJVM" {
179 index_bundled_djvm(&mut reader).await?
180 } else {
181 return Err(AsyncLazyError::Unsupported(
182 "lazy loader supports only FORM:DJVU and bundled FORM:DJVM",
183 ));
184 };
185
186 if pages.is_empty() {
187 return Err(AsyncLazyError::Unsupported(
188 "document has no lazy-loadable pages",
189 ));
190 }
191
192 let cache = (0..pages.len()).map(|_| OnceCell::new()).collect();
193 let shared_cache = shared
194 .keys()
195 .map(|id| (id.clone(), OnceCell::new()))
196 .collect();
197 Ok(Self {
198 reader: Arc::new(Mutex::new(reader)),
199 pages,
200 shared,
201 cache,
202 shared_cache,
203 })
204 }
205
206 pub fn page_count(&self) -> usize {
208 self.pages.len()
209 }
210
211 pub async fn page_async(&self, index: usize) -> Result<Arc<DjVuPage>, AsyncLazyError> {
213 let page = self
214 .pages
215 .get(index)
216 .ok_or(AsyncLazyError::PageOutOfRange {
217 index,
218 count: self.pages.len(),
219 })?
220 .clone();
221
222 self.cache[index]
223 .get_or_try_init(|| async move {
224 let bytes = self.read_page_bytes(page.range).await?;
225 let form = parse_form(&bytes)?;
226 let mut shared_djbz = None;
231 for incl in form.chunks.iter().filter(|c| &c.id == b"INCL") {
232 if let Ok(dict) = self.shared_djbz(incl.data).await {
233 shared_djbz = Some(dict);
234 break;
235 }
236 }
237 let page = DjVuDocument::parse_single_page_with_shared(&bytes, index, shared_djbz)?;
238 Ok(Arc::new(page))
239 })
240 .await
241 .cloned()
242 }
243
244 async fn shared_djbz(&self, incl: &[u8]) -> Result<Arc<SharedDict>, AsyncLazyError> {
245 let name = core::str::from_utf8(incl.trim_ascii_end())
246 .map_err(|_| AsyncLazyError::Unsupported("INCL name is not valid UTF-8"))?;
247 let cell = self
248 .shared_cache
249 .get(name)
250 .ok_or(AsyncLazyError::Unsupported("INCL target is not in DIRM"))?;
251 cell.get_or_try_init(|| async move {
252 let component = self
253 .shared
254 .get(name)
255 .ok_or(AsyncLazyError::Unsupported("INCL target is not in DIRM"))?
256 .clone();
257 let bytes = self.read_page_bytes(component.range).await?;
258 let form = parse_form(&bytes)?;
259 if form.form_type != *b"DJVI" {
260 return Err(AsyncLazyError::Unsupported("INCL target is not FORM:DJVI"));
261 }
262 let djbz = form.chunks.iter().find(|c| &c.id == b"Djbz").ok_or(
263 AsyncLazyError::Unsupported("DJVI component is missing Djbz"),
264 )?;
265 Ok(Arc::new(SharedDict::new(djbz.data.to_vec())))
266 })
267 .await
268 .cloned()
269 }
270
271 async fn read_page_bytes(&self, range: Range<u64>) -> Result<Vec<u8>, AsyncLazyError> {
272 let len = usize::try_from(range.end.saturating_sub(range.start))
273 .map_err(|_| AsyncLazyError::Unsupported("page range exceeds addressable memory"))?;
274 let mut bytes = Vec::with_capacity(len.saturating_add(4));
275 if range.start != 0 {
276 bytes.extend_from_slice(&MAGIC);
280 }
281 let mut reader = self.reader.lock().await;
282 reader.seek(std::io::SeekFrom::Start(range.start)).await?;
283 let mut chunk = vec![0u8; len];
284 reader.read_exact(&mut chunk).await?;
285 bytes.extend_from_slice(&chunk);
286 Ok(bytes)
287 }
288}
289
290pub async fn from_async_reader_lazy<R>(reader: R) -> Result<LazyDocument<R>, AsyncLazyError>
294where
295 R: AsyncRead + AsyncSeek + Unpin + Send + 'static,
296{
297 LazyDocument::from_async_reader_lazy(reader).await
298}
299
300#[cfg(target_arch = "wasm32")]
305pub async fn from_async_reader_lazy_local<R>(reader: R) -> Result<LazyDocument<R>, AsyncLazyError>
306where
307 R: AsyncRead + AsyncSeek + Unpin + 'static,
308{
309 LazyDocument::from_async_reader_lazy(reader).await
310}
311
312async fn index_bundled_djvm<R>(
313 reader: &mut R,
314) -> Result<(Vec<LazyPageIndex>, BTreeMap<String, LazyComponentIndex>), AsyncLazyError>
315where
316 R: AsyncRead + AsyncSeek + Unpin + 'static,
317{
318 let mut chunk_hdr = [0u8; 8];
319 reader.read_exact(&mut chunk_hdr).await?;
320 if &chunk_hdr[..4] != b"DIRM" {
321 return Err(AsyncLazyError::Unsupported(
322 "lazy DJVM loader requires DIRM as the first inner chunk",
323 ));
324 }
325 let dirm_len =
326 u32::from_be_bytes([chunk_hdr[4], chunk_hdr[5], chunk_hdr[6], chunk_hdr[7]]) as usize;
327 let padded = dirm_len + (dirm_len & 1);
328 let mut dirm = vec![0u8; padded];
329 reader.read_exact(&mut dirm).await?;
330
331 let entries = parse_lazy_dirm(&dirm[..dirm_len])?;
332 let mut pages = Vec::new();
333 let mut shared = BTreeMap::new();
334 for entry in entries {
335 let range = if entry.size > 8 {
340 entry.offset as u64..entry.offset as u64 + entry.size as u64
341 } else {
342 reader
343 .seek(std::io::SeekFrom::Start(entry.offset as u64 + 4))
344 .await?;
345 let mut size_bytes = [0u8; 4];
346 reader.read_exact(&mut size_bytes).await?;
347 crate::dirm::form_byte_range(entry.offset, size_bytes)
348 };
349 match entry.comp_type {
350 DirmComponentKind::Page => pages.push(LazyPageIndex { range }),
351 DirmComponentKind::Shared => {
352 shared.insert(entry.id, LazyComponentIndex { range });
353 }
354 DirmComponentKind::Thumbnail => {}
355 }
356 }
357 Ok((pages, shared))
358}
359
360fn parse_lazy_dirm(data: &[u8]) -> Result<Vec<LazyDirmEntry>, AsyncLazyError> {
361 let payload = DirmPayload::decode(data).map_err(AsyncLazyError::Unsupported)?;
362 if !payload.is_bundled() {
363 return Err(AsyncLazyError::Unsupported(
364 "indirect DJVM lazy loading is not implemented yet",
365 ));
366 }
367
368 Ok(payload
371 .components()
372 .into_iter()
373 .zip(payload.offsets)
374 .map(|(c, offset)| LazyDirmEntry {
375 comp_type: c.kind,
376 id: c.id,
377 offset,
378 size: c.size,
379 })
380 .collect())
381}
382
383pub async fn load_document_async_streaming<R>(mut reader: R) -> Result<DjVuDocument, AsyncLazyError>
407where
408 R: AsyncRead + Unpin + Send,
409{
410 let mut head = [0u8; 16];
412 reader.read_exact(&mut head).await?;
413
414 let is_djvm = &head[..4] == b"AT&T" && &head[4..8] == b"FORM" && &head[12..16] == b"DJVM";
417
418 let mut buf = Vec::with_capacity(if is_djvm {
419 1 << 20
421 } else {
422 16 * 1024
423 });
424 buf.extend_from_slice(&head);
425
426 if is_djvm {
427 let mut chunk_hdr = [0u8; 8];
429 reader.read_exact(&mut chunk_hdr).await?;
430 buf.extend_from_slice(&chunk_hdr);
431
432 if &chunk_hdr[..4] == b"DIRM" {
437 let dirm_len =
438 u32::from_be_bytes([chunk_hdr[4], chunk_hdr[5], chunk_hdr[6], chunk_hdr[7]])
439 as usize;
440 let padded = dirm_len + (dirm_len & 1);
443 let mut dirm_buf = vec![0u8; padded];
444 reader.read_exact(&mut dirm_buf).await?;
445 buf.extend_from_slice(&dirm_buf);
446 }
447 }
448
449 reader.read_to_end(&mut buf).await?;
451
452 Ok(DjVuDocument::parse(&buf)?)
453}
454
455pub fn render_progressive_stream(
492 page: &DjVuPage,
493 opts: RenderOptions,
494) -> impl futures_core::Stream<Item = Result<Pixmap, AsyncRenderError>> {
495 let page = Arc::new(page.clone());
498 let steps = djvu_render::progressive_steps(&page);
501
502 async_stream::stream! {
503 for step in 0..steps {
504 let page = Arc::clone(&page);
505 let opts = opts.clone();
506 let result = tokio::task::spawn_blocking(move || {
507 djvu_render::render_progressive_step(&page, &opts, step)
508 .map_err(AsyncRenderError::Render)
509 })
510 .await
511 .map_err(|e| AsyncRenderError::Join(e.to_string()));
512 yield result.and_then(|r| r);
513 }
514 }
515}
516
517pub async fn render_tile_async(
531 page: &DjVuPage,
532 opts: RenderOptions,
533 tile_size: u32,
534 col: u32,
535 row: u32,
536 controls: TileRenderControls,
537) -> Result<Pixmap, AsyncTileError> {
538 let page = page.clone();
539 tokio::task::spawn_blocking(move || {
540 crate::djvu_tile::render_tile_with(&page, &opts, tile_size, col, row, &controls)
541 .map_err(AsyncTileError::Tile)
542 })
543 .await
544 .map_err(|e| AsyncTileError::Join(e.to_string()))?
545}
546
547pub fn render_tile_progressive_stream(
561 page: &DjVuPage,
562 opts: RenderOptions,
563 tile_size: u32,
564 col: u32,
565 row: u32,
566 cancel: Option<TileCancelToken>,
567) -> impl futures_core::Stream<Item = Result<Pixmap, AsyncTileError>> {
568 let page = Arc::new(page.clone());
571 let steps = djvu_render::progressive_steps(&page);
572
573 async_stream::stream! {
574 for step in 0..steps {
575 let page = Arc::clone(&page);
576 let opts = opts.clone();
577 let controls = TileRenderControls {
578 quality_step: Some(step),
579 cancel: cancel.clone(),
580 use_cache: false,
581 };
582 let result = tokio::task::spawn_blocking(move || {
583 crate::djvu_tile::render_tile_with(&page, &opts, tile_size, col, row, &controls)
584 .map_err(AsyncTileError::Tile)
585 })
586 .await
587 .map_err(|e| AsyncTileError::Join(e.to_string()))
588 .and_then(|r| r);
589 let cancelled = matches!(result, Err(AsyncTileError::Tile(TileError::Cancelled)));
590 yield result;
591 if cancelled {
592 return;
593 }
594 }
595 }
596}
597
598#[cfg(test)]
601mod tests {
602 use super::*;
603 use crate::djvu_document::DjVuDocument;
604
605 fn assets_path() -> std::path::PathBuf {
606 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
607 .join("references/djvujs/library/assets")
608 }
609
610 fn load_doc(name: &str) -> DjVuDocument {
611 let data =
612 std::fs::read(assets_path().join(name)).unwrap_or_else(|_| panic!("{name} must exist"));
613 DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("{e}"))
614 }
615
616 #[tokio::test]
619 async fn spawn_blocking_render_matches_sync() {
620 let doc = load_doc("chicken.djvu");
621 let page = doc.page(0).unwrap();
622 let pw = page.width() as u32;
623 let ph = page.height() as u32;
624
625 let opts = RenderOptions {
626 width: pw,
627 height: ph,
628 ..Default::default()
629 };
630 let sync_pm = djvu_render::render_pixmap(page, &opts).expect("sync render must succeed");
631
632 let blocking_page = page.clone();
633 let blocking_opts = opts.clone();
634 let async_pm = tokio::task::spawn_blocking(move || {
635 djvu_render::render_pixmap(&blocking_page, &blocking_opts)
636 })
637 .await
638 .expect("spawn_blocking must not panic")
639 .expect("render must succeed");
640
641 assert_eq!(async_pm.width, pw);
642 assert_eq!(async_pm.height, ph);
643 assert_eq!(
644 sync_pm.data, async_pm.data,
645 "spawn_blocking and sync renders must match"
646 );
647 }
648
649 #[test]
651 fn async_render_error_display() {
652 let err = AsyncRenderError::Render(crate::djvu_render::RenderError::InvalidDimensions {
653 width: 0,
654 height: 0,
655 });
656 let s = err.to_string();
657 assert!(
658 s.contains("render error"),
659 "error must mention 'render error'"
660 );
661 }
662
663 #[tokio::test]
667 async fn progressive_stream_last_frame_matches_pixmap() {
668 use futures::StreamExt;
669 let doc = load_doc("chicken.djvu");
670 let page = doc.page(0).unwrap();
671 let opts = RenderOptions {
672 width: 100,
673 height: 80,
674 ..Default::default()
675 };
676
677 let stream = render_progressive_stream(page, opts.clone());
678 futures::pin_mut!(stream);
679
680 let mut frames: Vec<Pixmap> = Vec::new();
681 while let Some(result) = stream.next().await {
682 frames.push(result.expect("frame should succeed"));
683 }
684
685 assert!(!frames.is_empty(), "stream must yield at least one frame");
686
687 let expected = djvu_render::render_pixmap(page, &opts).expect("render_pixmap must succeed");
688 assert_eq!(
689 frames.last().unwrap().data,
690 expected.data,
691 "last frame must match render_pixmap"
692 );
693 }
694
695 #[tokio::test]
697 async fn progressive_stream_consistent_dimensions() {
698 use futures::StreamExt;
699 let doc = load_doc("chicken.djvu");
700 let page = doc.page(0).unwrap();
701 let n_chunks = page.bg44_chunks().len();
702 let opts = RenderOptions {
703 width: 100,
704 height: 80,
705 ..Default::default()
706 };
707
708 let stream = render_progressive_stream(page, opts);
709 futures::pin_mut!(stream);
710
711 let mut count = 0usize;
712 while let Some(result) = stream.next().await {
713 let frame = result.expect("frame should succeed");
714 assert_eq!(frame.width, 100);
715 assert_eq!(frame.height, 80);
716 count += 1;
717 }
718
719 let expected_count = if n_chunks == 0 { 1 } else { n_chunks };
720 assert_eq!(
721 count, expected_count,
722 "frame count must equal BG44 chunk count"
723 );
724 }
725
726 #[tokio::test]
731 async fn tile_async_matches_sync_tile() {
732 let doc = load_doc("chicken.djvu");
733 let page = doc.page(0).unwrap();
734 let opts = RenderOptions {
735 width: 100,
736 height: 80,
737 ..Default::default()
738 };
739
740 let sync_pm =
741 crate::djvu_tile::render_tile(page, &opts, 32, 1, 1).expect("sync tile must render");
742 let async_pm = render_tile_async(page, opts, 32, 1, 1, TileRenderControls::default())
743 .await
744 .expect("async tile must render");
745 assert_eq!(
746 sync_pm.data, async_pm.data,
747 "async tile must match sync tile"
748 );
749 }
750
751 #[tokio::test]
754 async fn tile_progressive_stream_frames_match_quality_steps() {
755 use futures::StreamExt;
756 let doc = load_doc("chicken.djvu");
757 let page = doc.page(0).unwrap();
758 let steps = djvu_render::progressive_steps(page);
759 let opts = RenderOptions {
760 width: 100,
761 height: 80,
762 ..Default::default()
763 };
764
765 let stream = render_tile_progressive_stream(page, opts.clone(), 32, 1, 1, None);
766 futures::pin_mut!(stream);
767
768 let mut frames: Vec<Pixmap> = Vec::new();
769 while let Some(result) = stream.next().await {
770 frames.push(result.expect("frame should succeed"));
771 }
772 assert_eq!(frames.len(), steps, "one frame per progressive step");
773
774 for (step, frame) in frames.iter().enumerate() {
775 let controls = TileRenderControls {
776 quality_step: Some(step),
777 ..Default::default()
778 };
779 let expected = crate::djvu_tile::render_tile_with(page, &opts, 32, 1, 1, &controls)
780 .expect("sync quality step must render");
781 assert_eq!(
782 frame.data, expected.data,
783 "stream frame {step} must match sync quality step"
784 );
785 }
786
787 let full = crate::djvu_tile::render_tile(page, &opts, 32, 1, 1).expect("full tile");
788 assert_eq!(
789 frames.last().unwrap().data,
790 full.data,
791 "last stream frame must be full quality"
792 );
793 }
794
795 #[tokio::test]
798 async fn tile_cancellation_surfaces_and_ends_stream() {
799 use futures::StreamExt;
800 let doc = load_doc("chicken.djvu");
801 let page = doc.page(0).unwrap();
802 let opts = RenderOptions {
803 width: 100,
804 height: 80,
805 ..Default::default()
806 };
807
808 let token = TileCancelToken::new();
809 token.cancel();
810
811 let controls = TileRenderControls {
812 cancel: Some(token.clone()),
813 ..Default::default()
814 };
815 let err = render_tile_async(page, opts.clone(), 32, 1, 1, controls)
816 .await
817 .expect_err("pre-cancelled render must fail");
818 assert!(
819 matches!(err, AsyncTileError::Tile(TileError::Cancelled)),
820 "expected Cancelled, got: {err}"
821 );
822
823 let stream = render_tile_progressive_stream(page, opts, 32, 1, 1, Some(token));
824 futures::pin_mut!(stream);
825 let mut items = 0usize;
826 while let Some(result) = stream.next().await {
827 items += 1;
828 assert!(
829 matches!(result, Err(AsyncTileError::Tile(TileError::Cancelled))),
830 "cancelled stream must only yield Cancelled"
831 );
832 }
833 assert_eq!(items, 1, "stream must end after the first Cancelled error");
834 }
835
836 #[tokio::test]
840 async fn streaming_loader_matches_sync_parse() {
841 let path = assets_path().join("chicken.djvu");
842 let sync_data = std::fs::read(&path).expect("sync read must succeed");
843 let async_doc = load_document_async_streaming(std::io::Cursor::new(sync_data.clone()))
844 .await
845 .expect("async load must succeed");
846 let sync_doc = DjVuDocument::parse(&sync_data).expect("sync parse must succeed");
847
848 assert_eq!(async_doc.page_count(), sync_doc.page_count());
849 for i in 0..sync_doc.page_count() {
850 let a = async_doc.page(i).expect("async page");
851 let s = sync_doc.page(i).expect("sync page");
852 assert_eq!(a.width(), s.width());
853 assert_eq!(a.height(), s.height());
854 }
855 }
856
857 #[tokio::test]
859 async fn streaming_loader_propagates_parse_error() {
860 let bogus = b"not a djvu file at all".to_vec();
861 let reader = std::io::Cursor::new(bogus);
862 let err = load_document_async_streaming(reader)
863 .await
864 .expect_err("must fail to parse garbage");
865 assert!(
866 matches!(err, AsyncLazyError::Parse(_)),
867 "expected Parse error, got {err:?}"
868 );
869 }
870
871 #[tokio::test]
874 async fn lazy_document_single_page_caches_arc_page() {
875 let path = assets_path().join("chicken.djvu");
876 let bytes = std::fs::read(&path).expect("read");
877 let sync_doc = DjVuDocument::parse(&bytes).expect("sync parse");
878
879 let lazy = from_async_reader_lazy(std::io::Cursor::new(bytes))
880 .await
881 .expect("lazy index");
882 assert_eq!(lazy.page_count(), 1);
883
884 let page_a = lazy.page_async(0).await.expect("lazy page");
885 let page_b = lazy.page_async(0).await.expect("lazy cached page");
886 assert!(
887 Arc::ptr_eq(&page_a, &page_b),
888 "repeat access must reuse cache"
889 );
890
891 let sync_page = sync_doc.page(0).expect("sync page");
892 assert_eq!(page_a.width(), sync_page.width());
893 assert_eq!(page_a.height(), sync_page.height());
894 }
895
896 #[tokio::test]
900 async fn lazy_document_multi_incl_page_resolves_shared_dict() {
901 let path = assets_path().join("czech.djvu");
902 let bytes = std::fs::read(&path).expect("read czech fixture");
903 let lazy = from_async_reader_lazy(std::io::Cursor::new(bytes))
904 .await
905 .expect("lazy index");
906 let page = lazy.page_async(1).await.expect("multi-INCL page loads");
907 let mask = page
908 .extract_mask()
909 .expect("mask decode must succeed")
910 .expect("page 1 has an Sjbz mask");
911 assert_eq!((mask.width, mask.height), (1095, 1750));
912 }
913
914 #[tokio::test]
917 async fn lazy_document_bundled_page_without_incl_matches_sync() {
918 let path = assets_path().join("colorbook.djvu");
919 let Ok(bytes) = std::fs::read(&path) else {
920 eprintln!("skip: {} missing", path.display());
921 return;
922 };
923 let sync_doc = DjVuDocument::parse(&bytes).expect("sync parse");
924 let lazy = from_async_reader_lazy(std::io::Cursor::new(bytes))
925 .await
926 .expect("lazy index");
927
928 assert_eq!(lazy.page_count(), sync_doc.page_count());
929 let page_index = (0..sync_doc.page_count())
930 .find(|&i| {
931 sync_doc
932 .page(i)
933 .expect("sync page")
934 .chunk_ids()
935 .iter()
936 .all(|id| id != b"INCL")
937 })
938 .expect("fixture must contain at least one page without INCL");
939
940 let lazy_page = lazy.page_async(page_index).await.expect("lazy page");
941 let sync_page = sync_doc.page(page_index).expect("sync page");
942 assert_eq!(lazy_page.width(), sync_page.width());
943 assert_eq!(lazy_page.height(), sync_page.height());
944 }
945
946 #[tokio::test]
947 async fn lazy_document_bundled_page_with_incl_uses_shared_dict() {
948 let mut p1 = crate::bitmap::Bitmap::new(32, 12);
949 let mut p2 = crate::bitmap::Bitmap::new(32, 12);
950 for y in 2..10 {
951 for x in 3..9 {
952 p1.set(x, y, true);
953 p2.set(x, y, true);
954 }
955 }
956 for y in 3..9 {
957 for x in 16..22 {
958 p1.set(x, y, true);
959 p2.set(x, y, true);
960 }
961 }
962
963 let bytes = crate::jb2_encode::encode_djvm_bundle_jb2(
964 &[p1.clone(), p2.clone()],
965 2,
966 crate::jb2_encode::BUNDLE_DEFAULT_DPI,
967 );
968 let sync_doc = DjVuDocument::parse(&bytes).expect("sync parse");
969 let lazy = from_async_reader_lazy(std::io::Cursor::new(bytes))
970 .await
971 .expect("lazy index");
972
973 assert_eq!(lazy.page_count(), 2);
974 let lazy_page = lazy.page_async(0).await.expect("lazy page");
975 assert!(lazy_page.raw_chunk(b"INCL").is_some());
976 let lazy_mask = lazy_page
977 .extract_mask()
978 .expect("lazy mask")
979 .expect("lazy mask present");
980 let sync_mask = sync_doc
981 .page(0)
982 .expect("sync page")
983 .extract_mask()
984 .expect("sync mask")
985 .expect("sync mask present");
986 assert_eq!(lazy_mask, sync_mask);
987 assert_eq!(lazy_mask, p1);
988 }
989
990 #[tokio::test]
991 async fn lazy_document_page_out_of_range() {
992 let path = assets_path().join("chicken.djvu");
993 let bytes = std::fs::read(&path).expect("read");
994 let lazy = from_async_reader_lazy(std::io::Cursor::new(bytes))
995 .await
996 .expect("lazy index");
997
998 let err = lazy
999 .page_async(1)
1000 .await
1001 .expect_err("page 1 is out of range");
1002 assert!(
1003 matches!(err, AsyncLazyError::PageOutOfRange { index: 1, count: 1 }),
1004 "unexpected error: {err:?}"
1005 );
1006 }
1007
1008 #[tokio::test]
1011 async fn lazy_document_no_page_entries_returns_unsupported() {
1012 use crate::dirm::DirmPayload;
1013 use crate::iff::{self as iff_mod, Chunk, EmitPart};
1014
1015 let dirm_payload =
1017 DirmPayload::build_bundled(1, &[0x00], &["shared.djvi".to_string()], &[]);
1018 let dirm = Chunk::Leaf {
1019 id: *b"DIRM",
1020 data: dirm_payload.encode(),
1021 };
1022 let stub_form: &[u8] = b"FORM\x00\x00\x00\x04DJVI";
1024 let djvm = iff_mod::partial_emit(
1025 *b"DJVM",
1026 &[EmitPart::Chunk(&dirm), EmitPart::Verbatim(stub_form)],
1027 )
1028 .expect("fits within u32");
1029
1030 let result = from_async_reader_lazy(std::io::Cursor::new(djvm)).await;
1031 assert!(result.is_err(), "Shared-only DJVM must error with no pages");
1032 if let Err(e) = result {
1033 assert!(
1034 matches!(e, AsyncLazyError::Unsupported(_)),
1035 "expected Unsupported, got {e:?}"
1036 );
1037 }
1038 }
1039
1040 #[tokio::test]
1043 async fn lazy_document_skips_thumbnail_dirm_entries() {
1044 let path = assets_path().join("DjVu3Spec_bundled.djvu");
1045 let Ok(bytes) = std::fs::read(&path) else {
1046 eprintln!("skip: {} missing", path.display());
1047 return;
1048 };
1049 let lazy = from_async_reader_lazy(std::io::Cursor::new(bytes.clone()))
1050 .await
1051 .expect("lazy index must succeed for DjVu3Spec");
1052 let sync_doc = DjVuDocument::parse(&bytes).expect("sync parse");
1053 assert_eq!(lazy.page_count(), sync_doc.page_count());
1055 assert!(lazy.page_count() > 0);
1056 }
1057
1058 #[tokio::test]
1061 async fn streaming_loader_matches_buffered() {
1062 let path = assets_path().join("DjVu3Spec_bundled.djvu");
1063 let Ok(bytes) = std::fs::read(&path) else {
1064 eprintln!("skip: {} missing", path.display());
1065 return;
1066 };
1067 let streamed = load_document_async_streaming(std::io::Cursor::new(bytes.clone()))
1068 .await
1069 .expect("streaming load must succeed");
1070 let buffered = DjVuDocument::parse(&bytes).expect("buffered parse");
1071
1072 assert_eq!(streamed.page_count(), buffered.page_count());
1073 for i in 0..buffered.page_count() {
1074 assert_eq!(streamed.page_byte_range(i), buffered.page_byte_range(i));
1075 }
1076 }
1077
1078 #[tokio::test]
1085 async fn streaming_loader_reads_head_before_body() {
1086 use std::sync::{Arc, Mutex};
1087
1088 let path = assets_path().join("DjVu3Spec_bundled.djvu");
1089 let Ok(bytes) = std::fs::read(&path) else {
1090 eprintln!("skip: {} missing", path.display());
1091 return;
1092 };
1093
1094 struct RecordingReader {
1095 inner: std::io::Cursor<Vec<u8>>,
1096 sizes: Arc<Mutex<Vec<usize>>>,
1097 }
1098 impl tokio::io::AsyncRead for RecordingReader {
1099 fn poll_read(
1100 mut self: std::pin::Pin<&mut Self>,
1101 _cx: &mut std::task::Context<'_>,
1102 buf: &mut tokio::io::ReadBuf<'_>,
1103 ) -> std::task::Poll<std::io::Result<()>> {
1104 let want = buf.remaining();
1105 let pos = self.inner.position() as usize;
1106 let src = self.inner.get_ref();
1107 let n = want.min(src.len().saturating_sub(pos));
1108 if n > 0 {
1109 buf.put_slice(&src[pos..pos + n]);
1110 self.inner.set_position((pos + n) as u64);
1111 }
1112 self.sizes.lock().unwrap().push(n);
1113 std::task::Poll::Ready(Ok(()))
1114 }
1115 }
1116
1117 let sizes = Arc::new(Mutex::new(Vec::new()));
1118 let reader = RecordingReader {
1119 inner: std::io::Cursor::new(bytes.clone()),
1120 sizes: Arc::clone(&sizes),
1121 };
1122 let _ = load_document_async_streaming(reader)
1123 .await
1124 .expect("streaming load must succeed");
1125
1126 let sizes = sizes.lock().unwrap().clone();
1127 let nonzero: Vec<usize> = sizes.into_iter().filter(|&n| n > 0).collect();
1129
1130 assert_eq!(nonzero[0], 16, "first read must be 16-byte IFF head");
1132 assert_eq!(nonzero[1], 8, "second read must be 8-byte chunk header");
1134 assert!(
1136 nonzero[2] < bytes.len() / 4,
1137 "third read should be the DIRM payload, well under the full body \
1138 (got {} bytes for a {} byte file)",
1139 nonzero[2],
1140 bytes.len()
1141 );
1142 }
1143
1144 #[tokio::test]
1146 async fn streaming_loader_propagates_io_error() {
1147 struct FailingReader;
1148 impl tokio::io::AsyncRead for FailingReader {
1149 fn poll_read(
1150 self: std::pin::Pin<&mut Self>,
1151 _cx: &mut std::task::Context<'_>,
1152 _buf: &mut tokio::io::ReadBuf<'_>,
1153 ) -> std::task::Poll<std::io::Result<()>> {
1154 std::task::Poll::Ready(Err(std::io::Error::other("simulated I/O failure")))
1155 }
1156 }
1157 let err = load_document_async_streaming(FailingReader)
1158 .await
1159 .expect_err("must fail on I/O error");
1160 assert!(
1161 matches!(err, AsyncLazyError::Io(_)),
1162 "expected Io error, got {err:?}"
1163 );
1164 }
1165
1166 #[tokio::test]
1168 async fn progressive_stream_jb2_only_yields_one_frame() {
1169 use futures::StreamExt;
1170 let doc = load_doc("boy_jb2.djvu");
1171 let page = doc.page(0).unwrap();
1172 if !page.bg44_chunks().is_empty() {
1173 return;
1175 }
1176 let opts = RenderOptions {
1177 width: 80,
1178 height: 60,
1179 ..Default::default()
1180 };
1181
1182 let stream = render_progressive_stream(page, opts);
1183 futures::pin_mut!(stream);
1184
1185 let mut count = 0;
1186 while let Some(result) = stream.next().await {
1187 result.expect("frame should succeed");
1188 count += 1;
1189 }
1190 assert_eq!(count, 1, "JB2-only page must yield exactly one frame");
1191 }
1192
1193 #[tokio::test]
1196 async fn lazy_not_att_form_returns_unsupported() {
1197 let bytes = b"XXXX0000XXXXXXXX"; let cursor = std::io::Cursor::new(bytes.to_vec());
1199 let result = from_async_reader_lazy(cursor).await;
1200 assert!(
1201 matches!(result, Err(AsyncLazyError::Unsupported(_))),
1202 "non-AT&T FORM must return Unsupported"
1203 );
1204 }
1205
1206 #[tokio::test]
1207 async fn lazy_indirect_djvm_returns_unsupported() {
1208 let indirect = crate::djvm::create_indirect(&["page1.djvu"]).expect("create_indirect");
1210 let cursor = std::io::Cursor::new(indirect);
1211 let result = from_async_reader_lazy(cursor).await;
1212 assert!(
1213 matches!(result, Err(AsyncLazyError::Unsupported(_))),
1214 "indirect DJVM must return Unsupported (not yet implemented)"
1215 );
1216 }
1217
1218 #[tokio::test]
1219 async fn lazy_djvm_with_only_shared_components_returns_unsupported() {
1220 use crate::dirm::DirmPayload;
1223 use crate::iff::{self as iff_djvm, Chunk as IffChunk, EmitPart as IffEmitPart};
1224 let dirm_payload =
1225 DirmPayload::build_bundled(1, &[0u8], &["shared".to_string()], &[]).encode();
1226 let dirm_chunk = IffChunk::Leaf {
1227 id: *b"DIRM",
1228 data: dirm_payload,
1229 };
1230 let bytes = iff_djvm::partial_emit(*b"DJVM", &[IffEmitPart::Chunk(&dirm_chunk)]).unwrap();
1231 let cursor = std::io::Cursor::new(bytes);
1232 let result = from_async_reader_lazy(cursor).await;
1233 assert!(
1236 matches!(
1237 result,
1238 Err(AsyncLazyError::Unsupported(_) | AsyncLazyError::Io(_))
1239 ),
1240 "DJVM with no page components must return Unsupported or Io error"
1241 );
1242 }
1243
1244 #[tokio::test]
1245 async fn lazy_djvm_without_dirm_first_returns_unsupported() {
1246 use crate::iff::{self as iff_nodirm, Chunk as IffChunkNd, EmitPart as IffEmitPartNd};
1249 let info = IffChunkNd::Leaf {
1250 id: *b"INFO",
1251 data: vec![],
1252 };
1253 let bytes = iff_nodirm::partial_emit(*b"DJVM", &[IffEmitPartNd::Chunk(&info)]).unwrap();
1254 let cursor = std::io::Cursor::new(bytes);
1255 let result = from_async_reader_lazy(cursor).await;
1256 assert!(
1257 matches!(result, Err(AsyncLazyError::Unsupported(_))),
1258 "DJVM without DIRM first must return Unsupported"
1259 );
1260 }
1261
1262 #[tokio::test]
1263 async fn lazy_unknown_form_type_returns_unsupported() {
1264 use crate::iff;
1266 let bytes = iff::partial_emit(*b"DJVX", &[]).unwrap();
1267 let cursor = std::io::Cursor::new(bytes);
1268 let result = from_async_reader_lazy(cursor).await;
1269 assert!(
1270 matches!(result, Err(AsyncLazyError::Unsupported(_))),
1271 "unknown FORM type must return Unsupported"
1272 );
1273 }
1274}