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//! ## Rendering off the async runtime
6//!
7//! The sync render entry points ([`djvu_render::render_pixmap`],
8//! [`djvu_render::render_gray8`]) are CPU-bound IW44/JB2 decode work. To keep
9//! them off the async runtime thread, call them inside
10//! [`tokio::task::spawn_blocking`]. [`DjVuPage`] implements [`Clone`], so the
11//! page moves into the blocking closure with no unsafe code:
12//!
13//! ```no_run
14//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
15//! use djvu_rs::djvu_document::DjVuDocument;
16//! use djvu_rs::djvu_render::{self, RenderOptions};
17//!
18//! let data = std::fs::read("file.djvu")?;
19//! let doc = DjVuDocument::parse(&data)?;
20//! let page = doc.page(0)?.clone();
21//! let opts = RenderOptions { width: 800, height: 600, ..Default::default() };
22//!
23//! let pixmap = tokio::task::spawn_blocking(move || {
24//!     djvu_render::render_pixmap(&page, &opts)
25//! })
26//! .await??; // outer `?`: join error (panic); inner `?`: RenderError
27//! println!("{}×{}", pixmap.width, pixmap.height);
28//! # Ok(()) }
29//! ```
30//!
31//! The render error type stays the typed [`djvu_render::RenderError`] — there
32//! is no wrapper enum to unwrap.
33//!
34//! ## Key public abstractions
35//!
36//! - [`LazyDocument`] — seek-based lazy indexing with a concurrent per-page cache
37//! - [`render_progressive_stream`] — streaming progressive render yielding one frame per BG44 chunk
38//! - [`render_tile_async`] / [`render_tile_progressive_stream`] — tile-first
39//!   rendering (#691) off the runtime thread, with quality steps and
40//!   cancellation
41//! - [`load_document_async_streaming`] — head-first async loader exposing per-page byte ranges
42
43use 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// ── Error types ───────────────────────────────────────────────────────────────
61
62/// Errors from async rendering.
63#[derive(Debug, thiserror::Error)]
64#[non_exhaustive]
65pub enum AsyncRenderError {
66    /// The underlying render failed.
67    #[error("render error: {0}")]
68    Render(#[from] RenderError),
69
70    /// The blocking task was cancelled or panicked.
71    #[error("spawn_blocking join error: {0}")]
72    Join(String),
73}
74
75/// Errors from async tile rendering (#691).
76#[derive(Debug, thiserror::Error)]
77#[non_exhaustive]
78pub enum AsyncTileError {
79    /// The underlying tile render failed — including
80    /// [`TileError::Cancelled`] when the token fired.
81    #[error("tile error: {0}")]
82    Tile(#[from] TileError),
83
84    /// The blocking task was cancelled or panicked.
85    #[error("spawn_blocking join error: {0}")]
86    Join(String),
87}
88
89/// Errors from async document loading (both the streaming loader and the true
90/// lazy loader). One enum spans the whole async "couldn't get the document /
91/// page" seam (#369).
92#[derive(Debug, thiserror::Error)]
93#[non_exhaustive]
94pub enum AsyncLazyError {
95    /// I/O error from the underlying async reader.
96    #[error("I/O error: {0}")]
97    Io(#[from] std::io::Error),
98
99    /// The fetched bytes failed to parse as a DjVu document.
100    #[error("parse error: {0}")]
101    Parse(#[from] DocError),
102
103    /// IFF container parse error while inspecting lazy page bytes.
104    #[error("IFF error: {0}")]
105    Iff(#[from] IffError),
106
107    /// Page index is out of range.
108    #[error("page index {index} is out of range (document has {count} pages)")]
109    PageOutOfRange { index: usize, count: usize },
110
111    /// This lazy-loading slice intentionally rejects a document shape.
112    #[error("unsupported lazy document shape: {0}")]
113    Unsupported(&'static str),
114}
115
116// ── True lazy async document loader (#233 Phase 3 PR1) ───────────────────────
117
118#[derive(Debug, Clone)]
119struct LazyDirmEntry {
120    comp_type: DirmComponentKind,
121    id: String,
122    offset: u32,
123    /// Component byte length from the DIRM size table, or 0 when the writer
124    /// left the table zeroed (then the loader probes the `FORM` header).
125    size: u32,
126}
127
128/// Native async lazy DjVu document.
129///
130/// This is the first Phase 3 slice for #233: it indexes a seekable async
131/// reader up front, then fetches and parses each page only when
132/// [`LazyDocument::page_async`] is called. Parsed pages are cached as
133/// `Arc<DjVuPage>` so callers can render them concurrently without borrowing
134/// the document across awaits.
135///
136/// Current scope:
137/// - single-page `FORM:DJVU`
138/// - bundled `FORM:DJVM` pages, including shared `DJVI` dictionaries referenced
139///   via `INCL`
140///
141/// WASM `!Send` readers are intentionally left to the next issue slice.
142pub 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    /// Build a native lazy document index from an async seekable reader.
165    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    /// Number of lazy-loadable pages.
207    pub fn page_count(&self) -> usize {
208        self.pages.len()
209    }
210
211    /// Fetch, parse, cache, and return page `index`.
212    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                // A page may INCL several components (shared annotations AND
227                // the symbol dictionary, #624) — take the first include that
228                // resolves to a DJVI actually holding a Djbz, skipping the
229                // rest instead of failing on them.
230                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            // Reconstruct a standalone document from the on-disk component
277            // range by prepending the IFF magic (the FORM framing is already
278            // present in the range read from disk).
279            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
290/// Build a native lazy document index from an async seekable reader.
291///
292/// Convenience wrapper around [`LazyDocument::from_async_reader_lazy`].
293pub 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/// Build a lazy document from a single-threaded WASM-local async reader.
301///
302/// This constructor intentionally drops the native `Send` bound for browser
303/// readers such as `wasm-bindgen-futures`/`gloo` streams.
304#[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        // The DIRM size table gives each component's byte length (FORM header
336        // included), so a populated table indexes the whole document from the
337        // head bytes alone — no seek across the file per component. Writers
338        // that zero the table (ours does) fall back to probing the FORM header.
339        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    // `components()` and `offsets` are both indexed by component, so zipping them
369    // pairs each entry with its FORM-header offset.
370    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
383// ── Async document loader ─────────────────────────────────────────────────────
384
385/// Async loader that reads the IFF + FORM + DIRM head separately from the
386/// page bodies (#196 Phase 2).
387///
388/// **Phase 2 of #196.** Issues two `read_exact` calls for the document head
389/// (IFF magic + FORM length + form_type, then the DIRM chunk header + payload),
390/// then a single `read_to_end` for the remainder. The total bytes received
391/// match Phase 1 — this constructor still returns an in-memory
392/// [`DjVuDocument`] — but a bandwidth-instrumented `AsyncRead` implementation
393/// can observe the head-first read pattern, and the resulting document
394/// exposes [`DjVuDocument::page_byte_range`] for any caller that wants to
395/// fan out per-page byte fetches via HTTP `Range` requests on a separate
396/// connection.
397///
398/// For documents that aren't bundled DJVM (single-page DJVU, indirect DJVM,
399/// or anything without a DIRM in the first chunk), this falls back to the
400/// Phase 1 buffered-read behavior — there's nothing useful to stream.
401///
402/// # Errors
403///
404/// - `AsyncLazyError::Io` — any underlying read fails
405/// - `AsyncLazyError::Parse` — the assembled buffer fails [`DjVuDocument::parse`]
406pub async fn load_document_async_streaming<R>(mut reader: R) -> Result<DjVuDocument, AsyncLazyError>
407where
408    R: AsyncRead + Unpin + Send,
409{
410    // 1) IFF outer header: 4-byte magic "AT&T" + "FORM" + 4-byte length + 4-byte form_type = 16 bytes.
411    let mut head = [0u8; 16];
412    reader.read_exact(&mut head).await?;
413
414    // If it isn't a DJVM bundle, the rest of the file is just page payload —
415    // no per-chunk streaming benefit, so fall back to bulk read.
416    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        // Pre-size: 1 MB head guess; Vec grows as needed.
420        1 << 20
421    } else {
422        16 * 1024
423    });
424    buf.extend_from_slice(&head);
425
426    if is_djvm {
427        // 2) Next chunk header: 4-byte id + 4-byte BE length.
428        let mut chunk_hdr = [0u8; 8];
429        reader.read_exact(&mut chunk_hdr).await?;
430        buf.extend_from_slice(&chunk_hdr);
431
432        // If the first inner chunk is DIRM, read its payload separately so
433        // a recording reader sees the head-first pattern. Otherwise just
434        // continue with read_to_end — the document layout is non-canonical
435        // and Phase 2's offset map wouldn't apply anyway.
436        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            // IFF chunks pad to 2-byte boundary; the parser handles this, but
441            // we must read those padding bytes too to keep alignment.
442            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    // 3) Bulk-read the remainder.
450    reader.read_to_end(&mut buf).await?;
451
452    Ok(DjVuDocument::parse(&buf)?)
453}
454
455// ── Async render functions ────────────────────────────────────────────────────
456
457/// Render a `DjVuPage` as a lazy progressive stream of [`Pixmap`] frames.
458///
459/// Yields one frame per BG44 wavelet refinement chunk: the first frame is the
460/// coarsest (fastest to produce), and each subsequent frame adds detail. The
461/// final frame is equivalent to [`render_pixmap`][djvu_render::render_pixmap].
462///
463/// If the page has no BG44 chunks (bilevel JB2-only pages), exactly one frame
464/// is yielded via [`render_pixmap`][djvu_render::render_pixmap].
465///
466/// Each frame is produced via [`tokio::task::spawn_blocking`] just before it is
467/// yielded, so the stream never blocks the async runtime thread.
468///
469/// # Example
470///
471/// ```no_run
472/// # async fn example() {
473/// use djvu_rs::djvu_document::DjVuDocument;
474/// use djvu_rs::djvu_render::RenderOptions;
475/// use djvu_rs::djvu_async::render_progressive_stream;
476/// use futures::StreamExt;
477///
478/// let data = std::fs::read("file.djvu").unwrap();
479/// let doc = DjVuDocument::parse(&data).unwrap();
480/// let page = doc.page(0).unwrap();
481/// let opts = RenderOptions { width: 800, height: 600, ..Default::default() };
482///
483/// let stream = render_progressive_stream(page, opts);
484/// futures::pin_mut!(stream);
485/// while let Some(pixmap) = stream.next().await {
486///     let pixmap = pixmap.unwrap();
487///     println!("{}×{}", pixmap.width, pixmap.height);
488/// }
489/// # }
490/// ```
491pub fn render_progressive_stream(
492    page: &DjVuPage,
493    opts: RenderOptions,
494) -> impl futures_core::Stream<Item = Result<Pixmap, AsyncRenderError>> {
495    // Single clone wrapped in Arc — all spawn_blocking closures share
496    // this one allocation instead of cloning the full page each time.
497    let page = Arc::new(page.clone());
498    // The "max(1, bg44 chunks)" frame count and the per-frame coarse/progressive
499    // choice live in the render module; the stream just drives the step index.
500    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
517/// Render one tile off the async runtime thread (#691).
518///
519/// The async counterpart of
520/// [`djvu_tile::render_tile_with`](crate::djvu_tile::render_tile_with): the
521/// render runs inside [`tokio::task::spawn_blocking`] and carries every byte
522/// guarantee of the sync entry point unchanged — default controls match
523/// [`djvu_tile::render_tile`](crate::djvu_tile::render_tile) exactly,
524/// `use_cache` matches the cached path, `quality_step: Some(k)` matches the
525/// tile's crop of progressive frame `k`.
526///
527/// Cancel from any thread or task by cancelling a clone of the token in
528/// `controls.cancel`; the render stops at its next checkpoint with
529/// [`TileError::Cancelled`] (wrapped in [`AsyncTileError::Tile`]).
530pub 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
547/// Progressive quality ladder for one tile, as a lazy stream (#691).
548///
549/// The tile-granular counterpart of [`render_progressive_stream`]: yields one
550/// frame per quality step `0..progressive_steps(page)`, coarsest first. Each
551/// frame is byte-identical to the matching crop of the full-page frame from
552/// [`render_progressive_step`](djvu_render::render_progressive_step) — so
553/// per-tile refinement and full-page refinement can be mixed freely in one
554/// viewer. Bilevel pages (no BG44 data) yield exactly one full-quality frame.
555///
556/// Each frame is produced via [`tokio::task::spawn_blocking`] just before it
557/// is yielded. If `cancel` fires, the stream yields one
558/// `Err(AsyncTileError::Tile(TileError::Cancelled))` and ends — the token is
559/// sticky, so no later step could succeed.
560pub 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    // Single clone wrapped in Arc — all spawn_blocking closures share
569    // this one allocation instead of cloning the full page each time.
570    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// ── Tests ─────────────────────────────────────────────────────────────────────
599
600#[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    /// Rendering inside `spawn_blocking` (the documented caller pattern)
617    /// produces a pixmap identical to a direct sync render.
618    #[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    /// `AsyncRenderError::Render` wraps `RenderError`.
650    #[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    // ── render_progressive_stream tests ──────────────────────────────────────
664
665    /// Last frame from the progressive stream matches `render_pixmap`.
666    #[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    /// Each successive frame has the same dimensions.
696    #[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    // ── render_tile_async / render_tile_progressive_stream tests ─────────────
727
728    /// `render_tile_async` with default controls matches the sync tile
729    /// renderer byte-for-byte.
730    #[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    /// The tile stream yields one frame per progressive step, each
752    /// byte-identical to the sync quality-step render, ending at full quality.
753    #[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    /// A cancelled token makes `render_tile_async` fail with
796    /// `TileError::Cancelled` and ends the tile stream after one error.
797    #[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    // ── load_document_async_streaming tests ──────────────────────────────────
837
838    /// The streaming loader over an async reader matches `DjVuDocument::parse`.
839    #[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    /// Truncated / non-DjVu bytes surface as `AsyncLazyError::Parse`, not panic.
858    #[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    /// `LazyDocument` fetches and parses a single-page document only when
872    /// `page_async` is called, then returns the cached `Arc` on repeat access.
873    #[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    /// #624: a lazy page with several `INCL` chunks (shared annotations +
897    /// symbol dictionaries) must skip includes without a `Djbz` instead of
898    /// failing on the first one.
899    #[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    /// `LazyDocument` indexes bundled DJVM ranges up front and can fetch a
915    /// no-INCL page without reading/parsing the full document body.
916    #[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    /// A bundled DJVM with no Page DIRM entries (only Shared): the lazy loader
1009    /// returns Unsupported with "no lazy-loadable pages" (lines 164–165).
1010    #[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        // Build a bundled DIRM with 1 Shared entry (flag=0x00 = Shared)
1016        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        // Also add a stub sub-FORM so the offset-based read doesn't panic
1023        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    /// `LazyDocument` correctly skips THUM (thumbnail) DIRM entries while
1041    /// indexing a bundled DJVM — exercises the Thumbnail arm in index_bundled_djvm.
1042    #[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        // THUM entries are skipped so page count must still match
1054        assert_eq!(lazy.page_count(), sync_doc.page_count());
1055        assert!(lazy.page_count() > 0);
1056    }
1057
1058    /// `load_document_async_streaming` produces the same document as
1059    /// the buffered Phase 1 loader on a bundled DJVM.
1060    #[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    /// `load_document_async_streaming` reads the head before the body
1079    /// (#196 Phase 2 DoD).
1080    ///
1081    /// A custom `AsyncRead` records every requested read size. The first
1082    /// three calls must be small and bounded (IFF head 16 B, chunk header
1083    /// 8 B, DIRM payload — typically a few KB on a real document).
1084    #[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        // Strip 0-byte tail reads (EOF signals from read_to_end).
1128        let nonzero: Vec<usize> = sizes.into_iter().filter(|&n| n > 0).collect();
1129
1130        // First read: the 16-byte IFF + FORM + form_type head.
1131        assert_eq!(nonzero[0], 16, "first read must be 16-byte IFF head");
1132        // Second read: the 8-byte DIRM chunk header.
1133        assert_eq!(nonzero[1], 8, "second read must be 8-byte chunk header");
1134        // Third read: the DIRM payload — must be smaller than the full body.
1135        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    /// I/O failure surfaces as `AsyncLazyError::Io`, not panic.
1145    #[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    /// A JB2-only page (no BG44 chunks) yields exactly one frame.
1167    #[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            // Page is not JB2-only; skip
1174            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    // ── from_async_reader_lazy error paths ────────────────────────────────────
1194
1195    #[tokio::test]
1196    async fn lazy_not_att_form_returns_unsupported() {
1197        let bytes = b"XXXX0000XXXXXXXX"; // 16 bytes, not AT&T FORM
1198        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        // create_indirect builds a non-bundled DJVM; parse_lazy_dirm rejects it
1209        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        // Build a DJVM where DIRM has 1 Shared component (no Pages).
1221        // After index_bundled_djvm returns empty pages, lines 164-165 fire.
1222        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        // The function should return Unsupported because pages is empty (line 164-165).
1234        // Any error result covers the tested branches.
1235        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        // Valid AT&T FORM:DJVM but first inner chunk is INFO (not DIRM)
1247        // → triggers lines 293-294 in index_bundled_djvm
1248        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        // Valid AT&T FORM header but with an unrecognized form type "DJVX"
1265        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}