Skip to main content

djvu_rs/
djvu_tile.rs

1//! Tile-first progressive rendering API for viewer engines (#691, slices 1–2).
2//!
3//! This module formalizes a tile-oriented contract on top of the existing
4//! region renderer: a page render at a chosen output size is partitioned into
5//! a grid of fixed-size square tiles addressed in **display space** — the
6//! pixel space of the final, post-rotation output that a viewer puts on
7//! screen. See `docs/tile-rendering.md` for the full written contract.
8//!
9//! Guarantees (slice 1):
10//!
11//! - **Coordinate space.** Tile `(col, row)` covers display-space rectangle
12//!   `[col·ts, min((col+1)·ts, W)) × [row·ts, min((row+1)·ts, H))` where
13//!   `W × H` is the display canvas ([`TileLayout::output_width`] /
14//!   [`TileLayout::output_height`]) and `ts` the tile size. Edge tiles are
15//!   clipped, never padded.
16//! - **Assembly parity.** Blitting every tile at its display rectangle
17//!   reproduces [`render_pixmap`](crate::djvu_render::render_pixmap) output
18//!   byte-for-byte (bilinear resampling; all rotations).
19//! - **Order independence.** Tile pixels are a pure function of the tile
20//!   coordinate and the render options; request order (and cache state, for
21//!   [`render_tile_cached`]) never changes a single byte.
22//!
23//! Slice 2 adds cache control at tile granularity: [`tile_cache_usage`],
24//! [`set_tile_cache_budget`], [`clear_tile_cache`],
25//! [`invalidate_tile_region`], and (with the `parallel` feature) bounded
26//! background [`prefetch_tiles`]. Cache state never changes rendered bytes —
27//! only latency.
28//!
29//! Slice 3 adds explicit progressive quality steps and cooperative
30//! cancellation through [`render_tile_with`] / [`TileRenderControls`] /
31//! [`TileCancelToken`] (plus [`prefetch_tiles_cancellable`]):
32//!
33//! - **Quality steps.** `quality_step = Some(k)` renders the tile from BG44
34//!   background chunks `0..=k` only — byte-identical to the matching crop of
35//!   [`render_progressive_step`](crate::djvu_render::render_progressive_step)
36//!   frame `k`. Each later step only *adds* wavelet refinement over the same
37//!   base image, so walking steps `0..progressive_steps` never regresses
38//!   detail — the same monotonic ladder full-page progressive rendering
39//!   already rides.
40//! - **Cancellation.** A cancelled token makes in-flight work stop at its
41//!   next checkpoint (per tile, and between decode and composite) with
42//!   [`TileError::Cancelled`]. Cancellation never corrupts caches and never
43//!   changes the bytes of any completed tile.
44//!
45//! Layer selection, Lanczos tile aprons, and async/wasm surfaces are later
46//! slices of #691.
47
48#[cfg(not(feature = "std"))]
49use alloc::sync::Arc;
50#[cfg(feature = "std")]
51use std::sync::Arc;
52
53use crate::djvu_document::DjVuPage;
54use crate::djvu_render::{
55    RenderError, RenderOptions, RenderRect, Resampling, combine_rotations, render_region,
56};
57use crate::info::Rotation;
58use crate::pixmap::Pixmap;
59
60/// Error type for the tile API.
61#[derive(Debug, thiserror::Error)]
62#[non_exhaustive]
63pub enum TileError {
64    /// The underlying region render failed.
65    #[error(transparent)]
66    Render(#[from] RenderError),
67
68    /// The tile size is zero.
69    #[error("tile size must be non-zero")]
70    InvalidTileSize,
71
72    /// The operation was abandoned because its [`TileCancelToken`] was
73    /// cancelled.
74    #[error("tile operation cancelled")]
75    Cancelled,
76
77    /// The tile coordinate lies outside the tile grid.
78    #[error("tile ({col}, {row}) out of range for a {cols}x{rows} tile grid")]
79    OutOfRange {
80        /// Requested column.
81        col: u32,
82        /// Requested row.
83        row: u32,
84        /// Number of columns in the grid.
85        cols: u32,
86        /// Number of rows in the grid.
87        rows: u32,
88    },
89}
90
91/// A tile's rectangle in **display space** (post-rotation output pixels).
92///
93/// Distinct from [`RenderRect`], whose offsets live in the pre-rotation
94/// render canvas: a `TileRect` is where the tile lands on the viewer's
95/// screen, `x` growing right and `y` growing down from the top-left corner
96/// of the rotated page image.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct TileRect {
99    /// X offset in display pixels.
100    pub x: u32,
101    /// Y offset in display pixels.
102    pub y: u32,
103    /// Width in display pixels.
104    pub width: u32,
105    /// Height in display pixels.
106    pub height: u32,
107}
108
109/// The tile grid for one page render: display canvas size, tile size, and
110/// the coordinate mapping induced by the combined (INFO + user) rotation.
111///
112/// A layout is a pure value derived from `(page, opts, tile_size)`; building
113/// it decodes nothing. Rebuilding it with equal inputs yields an equal value,
114/// so callers may construct it per request or hold on to it — the rendered
115/// pixels depend only on the inputs, never on layout identity.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub struct TileLayout {
118    /// Pre-rotation full render canvas (`opts.width/height`, min 1).
119    full_width: u32,
120    full_height: u32,
121    /// Post-rotation display canvas.
122    output_width: u32,
123    output_height: u32,
124    tile_size: u32,
125    /// Combined INFO-chunk + user rotation.
126    rotation: Rotation,
127}
128
129impl TileLayout {
130    /// Build the tile grid for rendering `page` with `opts` at `tile_size`.
131    ///
132    /// # Errors
133    ///
134    /// - [`TileError::InvalidTileSize`] if `tile_size == 0`.
135    /// - [`RenderError::UnsupportedOption`] if `opts.resampling` is
136    ///   [`Resampling::Lanczos3`] or `opts.aa` is set: both are
137    ///   whole-pixmap post-passes (windowed resampling; 2× downscale that
138    ///   halves the output) and do not commute with per-tile assembly, so
139    ///   tiles could not honor the assembly-parity guarantee (follow-up in
140    ///   #691). For a smaller output, set `opts.width`/`opts.height` to the
141    ///   target size instead of relying on the `aa` halving.
142    pub fn new(page: &DjVuPage, opts: &RenderOptions, tile_size: u32) -> Result<Self, TileError> {
143        if tile_size == 0 {
144            return Err(TileError::InvalidTileSize);
145        }
146        if opts.resampling == Resampling::Lanczos3 {
147            return Err(TileError::Render(RenderError::UnsupportedOption(
148                "Resampling::Lanczos3 does not commute with per-tile assembly (#691)",
149            )));
150        }
151        if opts.aa {
152            return Err(TileError::Render(RenderError::UnsupportedOption(
153                "the aa halving post-pass does not commute with per-tile assembly (#691); \
154                 request the target size directly instead",
155            )));
156        }
157        let full_width = opts.width.max(1);
158        let full_height = opts.height.max(1);
159        let rotation = combine_rotations(page.rotation(), opts.rotation);
160        let (output_width, output_height) = match rotation {
161            Rotation::None | Rotation::Rot180 => (full_width, full_height),
162            Rotation::Cw90 | Rotation::Ccw90 => (full_height, full_width),
163        };
164        Ok(TileLayout {
165            full_width,
166            full_height,
167            output_width,
168            output_height,
169            tile_size,
170            rotation,
171        })
172    }
173
174    /// Display canvas width (post-rotation), in pixels.
175    pub fn output_width(&self) -> u32 {
176        self.output_width
177    }
178
179    /// Display canvas height (post-rotation), in pixels.
180    pub fn output_height(&self) -> u32 {
181        self.output_height
182    }
183
184    /// Tile edge length in pixels (edge tiles may be smaller).
185    pub fn tile_size(&self) -> u32 {
186        self.tile_size
187    }
188
189    /// Number of tile columns (`ceil(output_width / tile_size)`).
190    pub fn cols(&self) -> u32 {
191        self.output_width.div_ceil(self.tile_size)
192    }
193
194    /// Number of tile rows (`ceil(output_height / tile_size)`).
195    pub fn rows(&self) -> u32 {
196        self.output_height.div_ceil(self.tile_size)
197    }
198
199    /// Total number of tiles in the grid.
200    pub fn tile_count(&self) -> u64 {
201        u64::from(self.cols()) * u64::from(self.rows())
202    }
203
204    /// The display-space rectangle covered by tile `(col, row)`.
205    ///
206    /// Edge tiles are clipped to the canvas; no tile is ever padded.
207    ///
208    /// # Errors
209    ///
210    /// [`TileError::OutOfRange`] if `col >= cols()` or `row >= rows()`.
211    pub fn tile_rect(&self, col: u32, row: u32) -> Result<TileRect, TileError> {
212        let (cols, rows) = (self.cols(), self.rows());
213        if col >= cols || row >= rows {
214            return Err(TileError::OutOfRange {
215                col,
216                row,
217                cols,
218                rows,
219            });
220        }
221        let x = col * self.tile_size;
222        let y = row * self.tile_size;
223        Ok(TileRect {
224            x,
225            y,
226            width: self.tile_size.min(self.output_width - x),
227            height: self.tile_size.min(self.output_height - y),
228        })
229    }
230
231    /// Map a display-space rectangle to the pre-rotation [`RenderRect`] whose
232    /// rotated render equals that display rectangle.
233    ///
234    /// The region renderer selects its sub-rectangle before applying the
235    /// combined rotation, then rotates the small result
236    /// (`rotate_pixmap` runs last in `render_region`), so the display
237    /// rectangle must be pulled back through the inverse rotation. With
238    /// `(W, H)` the pre-rotation canvas and `(x, y, w, h)` the display rect:
239    ///
240    /// | combined rotation | pre-rotation rect |
241    /// |---|---|
242    /// | `None`  | `(x, y, w, h)` |
243    /// | `Cw90`  | `(y, H − x − w, h, w)` |
244    /// | `Rot180`| `(W − x − w, H − y − h, w, h)` |
245    /// | `Ccw90` | `(W − y − h, x, h, w)` |
246    fn to_render_rect(self, r: TileRect) -> RenderRect {
247        let (fw, fh) = (self.full_width, self.full_height);
248        match self.rotation {
249            Rotation::None => RenderRect {
250                x: r.x,
251                y: r.y,
252                width: r.width,
253                height: r.height,
254            },
255            Rotation::Cw90 => RenderRect {
256                x: r.y,
257                y: fh - r.x - r.width,
258                width: r.height,
259                height: r.width,
260            },
261            Rotation::Rot180 => RenderRect {
262                x: fw - r.x - r.width,
263                y: fh - r.y - r.height,
264                width: r.width,
265                height: r.height,
266            },
267            Rotation::Ccw90 => RenderRect {
268                x: fw - r.y - r.height,
269                y: r.x,
270                width: r.height,
271                height: r.width,
272            },
273        }
274    }
275}
276
277/// Render one tile of `page` at the grid position `(col, row)`.
278///
279/// `opts.width`/`opts.height` define the full-page render size exactly as for
280/// [`render_pixmap`](crate::djvu_render::render_pixmap); the returned pixmap
281/// has the dimensions of [`TileLayout::tile_rect`] for `(col, row)` and its
282/// pixels are byte-identical to that rectangle of the full-page render.
283///
284/// Every call recomposites the tile from the page's cached decoded layers.
285/// For interactive viewers prefer [`render_tile_cached`], which memoizes
286/// composited output.
287///
288/// # Errors
289///
290/// - [`TileError::InvalidTileSize`] / [`TileError::OutOfRange`] for grid
291///   violations, [`RenderError::UnsupportedOption`] for Lanczos-3 resampling.
292/// - Propagates decode and resource-limit errors from the region renderer.
293pub fn render_tile(
294    page: &DjVuPage,
295    opts: &RenderOptions,
296    tile_size: u32,
297    col: u32,
298    row: u32,
299) -> Result<Pixmap, TileError> {
300    let layout = TileLayout::new(page, opts, tile_size)?;
301    let rect = layout.tile_rect(col, row)?;
302    Ok(render_region(page, layout.to_render_rect(rect), opts)?)
303}
304
305/// Render one tile, assembling it from the page's composited-tile cache.
306///
307/// Byte-identical to [`render_tile`] for every input — this routes through
308/// [`render_region_tiled`](crate::djvu_render::render_region_tiled), a cache
309/// in front of the same compositor (falling back to a plain region render
310/// whenever the cache is not eligible). Request order never affects output:
311/// cache entries are keyed by absolute position in the full render, so hits
312/// and misses reproduce the same bytes.
313///
314/// # Errors
315///
316/// Same as [`render_tile`].
317#[cfg(feature = "std")]
318pub fn render_tile_cached(
319    page: &DjVuPage,
320    opts: &RenderOptions,
321    tile_size: u32,
322    col: u32,
323    row: u32,
324) -> Result<Pixmap, TileError> {
325    let layout = TileLayout::new(page, opts, tile_size)?;
326    let rect = layout.tile_rect(col, row)?;
327    Ok(crate::djvu_render::render_region_tiled(
328        page,
329        layout.to_render_rect(rect),
330        opts,
331    )?)
332}
333
334/// Cooperative cancellation token for tile work (#691 slice 3).
335///
336/// Clones share one flag: cancel any clone and every operation holding a
337/// clone stops at its next checkpoint with [`TileError::Cancelled`].
338/// Checkpoints sit *between* units of work — before each tile, before each
339/// internal cache tile, and between layer decode and composite — so an
340/// in-flight decode always runs to completion; cancellation bounds further
341/// work, not the current unit. A token is one-way: once cancelled it stays
342/// cancelled (create a fresh token per request generation instead of
343/// resetting).
344///
345/// Cancellation never changes rendered bytes and never corrupts caches:
346/// work either completes a unit fully or abandons it without publishing
347/// anything partial.
348#[derive(Debug, Clone, Default)]
349pub struct TileCancelToken {
350    flag: Arc<core::sync::atomic::AtomicBool>,
351}
352
353impl TileCancelToken {
354    /// A fresh, un-cancelled token.
355    pub fn new() -> Self {
356        Self::default()
357    }
358
359    /// Signal every holder of a clone of this token to stop.
360    pub fn cancel(&self) {
361        self.flag.store(true, core::sync::atomic::Ordering::Relaxed);
362    }
363
364    /// Whether [`cancel`](Self::cancel) has been called on any clone.
365    pub fn is_cancelled(&self) -> bool {
366        self.flag.load(core::sync::atomic::Ordering::Relaxed)
367    }
368
369    /// The raw flag the render internals poll.
370    fn as_flag(&self) -> &core::sync::atomic::AtomicBool {
371        &self.flag
372    }
373}
374
375/// Per-call controls for [`render_tile_with`] (#691 slice 3).
376///
377/// The default value reproduces [`render_tile`] exactly: full quality, no
378/// cancellation, no composited-tile cache.
379#[derive(Debug, Clone, Default)]
380pub struct TileRenderControls {
381    /// Progressive quality step, `0..progressive_steps(page)` (see
382    /// [`progressive_steps`](crate::djvu_render::progressive_steps)).
383    ///
384    /// `Some(k)` composites the tile from BG44 background chunks `0..=k`
385    /// only — byte-identical to the matching crop of
386    /// [`render_progressive_step`](crate::djvu_render::render_progressive_step)
387    /// frame `k`. `None` (default) renders full quality, byte-identical to
388    /// [`render_tile`]. Partial-quality pixels are decoded per call and are
389    /// never stored in the composited-tile cache, so a later full-quality
390    /// render can never be polluted by a lower step.
391    pub quality_step: Option<usize>,
392
393    /// Cooperative cancellation token; see [`TileCancelToken`].
394    pub cancel: Option<TileCancelToken>,
395
396    /// Assemble the tile from the page's composited-tile cache when
397    /// eligible, exactly like [`render_tile_cached`]. Ignored when
398    /// `quality_step` selects a progressive frame (partial-quality tiles
399    /// are never cached).
400    #[cfg(feature = "std")]
401    pub use_cache: bool,
402}
403
404/// Render one tile under explicit [`TileRenderControls`] (#691 slice 3).
405///
406/// One entry point for the whole matrix: quality steps × cancellation ×
407/// cache assembly. Byte guarantees per mode:
408///
409/// - default controls ⇒ identical to [`render_tile`];
410/// - `use_cache` ⇒ identical to [`render_tile_cached`] (which is itself
411///   byte-identical to [`render_tile`]);
412/// - `quality_step: Some(k)` ⇒ identical to the tile's crop of
413///   [`render_progressive_step`](crate::djvu_render::render_progressive_step)
414///   frame `k`; on pages without BG44 background data the single step `0`
415///   is the full render (mirroring `render_progressive_step`'s fallback).
416///
417/// # Errors
418///
419/// - Everything [`render_tile`] can return.
420/// - [`TileError::Cancelled`] if `controls.cancel` was cancelled before or
421///   during the render.
422/// - [`RenderError::ChunkOutOfRange`] if `quality_step` is
423///   `Some(k)` with `k >= progressive_steps(page)`.
424pub fn render_tile_with(
425    page: &DjVuPage,
426    opts: &RenderOptions,
427    tile_size: u32,
428    col: u32,
429    row: u32,
430    controls: &TileRenderControls,
431) -> Result<Pixmap, TileError> {
432    let layout = TileLayout::new(page, opts, tile_size)?;
433    let rect = layout.tile_rect(col, row)?;
434    let cancel = controls.cancel.as_ref();
435    if cancel.is_some_and(TileCancelToken::is_cancelled) {
436        return Err(TileError::Cancelled);
437    }
438    let flag = cancel.map(TileCancelToken::as_flag);
439    let render_rect = layout.to_render_rect(rect);
440
441    if let Some(step) = controls.quality_step {
442        let steps = crate::djvu_render::progressive_steps(page);
443        if step >= steps {
444            return Err(TileError::Render(RenderError::ChunkOutOfRange {
445                chunk_n: step,
446                max: steps - 1,
447            }));
448        }
449        if page.bg44_chunks().is_empty() {
450            // No BG44 refinement ladder: the single step is the full render
451            // (mirrors `render_progressive_step`'s fallback).
452            return Ok(render_region(page, render_rect, opts)?);
453        }
454        return crate::djvu_render::render_region_progressive(page, render_rect, opts, step, flag)?
455            .ok_or(TileError::Cancelled);
456    }
457
458    #[cfg(feature = "std")]
459    if controls.use_cache {
460        return crate::djvu_render::render_region_tiled_cancellable(page, render_rect, opts, flag)?
461            .ok_or(TileError::Cancelled);
462    }
463
464    Ok(render_region(page, render_rect, opts)?)
465}
466
467/// Snapshot of one page's composited-tile cache (#691 slice 2).
468///
469/// The cache stores *internal* 256-pixel composited tiles (the granularity of
470/// [`render_region_tiled`](crate::djvu_render::render_region_tiled)), which
471/// back any caller-chosen [`render_tile_cached`] grid. `tiles` therefore
472/// counts internal tiles, not caller tiles.
473#[cfg(feature = "std")]
474#[derive(Debug, Clone, Copy, PartialEq, Eq)]
475pub struct TileCacheUsage {
476    /// Bytes currently held by cached composited tiles.
477    pub bytes: usize,
478    /// Byte budget the cache enforces. Defaults to 8 MiB per page; override
479    /// with [`set_tile_cache_budget`].
480    pub budget: usize,
481    /// Number of cached internal tiles.
482    pub tiles: usize,
483}
484
485/// Current usage of `page`'s composited-tile cache.
486///
487/// Reading usage never renders or decodes anything.
488#[cfg(feature = "std")]
489pub fn tile_cache_usage(page: &DjVuPage) -> TileCacheUsage {
490    let layers = page.render_layers();
491    TileCacheUsage {
492        bytes: layers.tile_cache_bytes(),
493        budget: layers.tile_cache_budget(),
494        tiles: layers.tile_cache_len(),
495    }
496}
497
498/// Override `page`'s composited-tile cache byte budget (#691 slice 2).
499///
500/// Takes effect immediately: if the cache currently holds more than
501/// `max_bytes`, the oldest tiles are evicted until it fits. A budget of `0`
502/// effectively disables composited-tile caching for this page —
503/// [`render_tile_cached`] stays correct, it just stops being warm.
504///
505/// The override is kept when the document's budget sweep *downgrades* the
506/// page (`DjVuDocument::downgrade_render_caches`), but is reset to the
507/// default when the page's whole render cache is dropped
508/// (`DjVuPage::evict_render_cache`, `DjVuDocument::evict_render_caches`, or
509/// an `enforce_cache_budget` eviction): the budget lives with the cache it
510/// bounds.
511#[cfg(feature = "std")]
512pub fn set_tile_cache_budget(page: &DjVuPage, max_bytes: usize) {
513    page.render_layers().set_tile_cache_budget(max_bytes);
514}
515
516/// Drop every cached composited tile of `page`, returning the bytes freed.
517///
518/// Decoded layers (mask, background, foreground) stay cached; only the
519/// compositor's memoized output is invalidated. A budget override set via
520/// [`set_tile_cache_budget`] survives.
521#[cfg(feature = "std")]
522pub fn clear_tile_cache(page: &DjVuPage) -> usize {
523    page.render_layers().clear_tile_cache()
524}
525
526/// Invalidate every cached composited tile that intersects `region`,
527/// returning the bytes freed (#691 slice 2).
528///
529/// `region` is a **display-space** rectangle under `opts` — the same
530/// coordinate space as [`TileLayout::tile_rect`]; it is clipped to the
531/// display canvas. Cached tiles are dropped across **all** cached render
532/// sizes of the page, not just `opts.width × opts.height`: the region is
533/// mapped proportionally into each cached size, rounding outward, so a tile
534/// that touches the region at any scale is dropped rather than kept. Tiles
535/// wholly outside the region stay warm.
536///
537/// # Errors
538///
539/// [`RenderError::UnsupportedOption`] for Lanczos-3 resampling or `aa`
540/// (same eligibility as [`TileLayout::new`]).
541#[cfg(feature = "std")]
542pub fn invalidate_tile_region(
543    page: &DjVuPage,
544    opts: &RenderOptions,
545    region: TileRect,
546) -> Result<usize, TileError> {
547    // Tile size is irrelevant here — the layout is only used for its canvas
548    // dimensions and rotation pull-back.
549    let layout = TileLayout::new(page, opts, 1)?;
550    let x = region.x.min(layout.output_width);
551    let y = region.y.min(layout.output_height);
552    let width = region.width.min(layout.output_width - x);
553    let height = region.height.min(layout.output_height - y);
554    if width == 0 || height == 0 {
555        return Ok(0);
556    }
557    let rect = layout.to_render_rect(TileRect {
558        x,
559        y,
560        width,
561        height,
562    });
563    Ok(page
564        .render_layers()
565        .remove_tiles_intersecting(rect, layout.full_width, layout.full_height))
566}
567
568/// Schedule a bounded background prefetch of the tiles around `(col, row)`
569/// (#691 slice 2), returning how many tiles were scheduled.
570///
571/// Warms the same composited-tile cache [`render_tile_cached`] reads, for
572/// every grid tile within Chebyshev distance `radius` of the center tile
573/// (at most `(2·radius + 1)²`, clipped to the grid; `radius = 0` prefetches
574/// just the center tile). The work runs on the shared rayon pool; whichever
575/// side finishes a tile first populates the cache, the other observes it —
576/// there is no separate prefetch buffer to race against.
577///
578/// This is a hint, not a guarantee: an out-of-range `page_index` is a no-op
579/// returning `Ok(0)`, and decode errors inside the background task are
580/// swallowed — a later foreground [`render_tile_cached`] call will surface
581/// them. Retained bytes stay bounded by the page's tile-cache budget.
582///
583/// # Errors
584///
585/// Same as [`render_tile`] for grid violations and rejected options; the
586/// center tile must lie inside the grid.
587#[cfg(all(feature = "std", feature = "parallel"))]
588pub fn prefetch_tiles(
589    doc: &std::sync::Arc<crate::djvu_document::DjVuDocument>,
590    page_index: usize,
591    opts: &RenderOptions,
592    tile_size: u32,
593    col: u32,
594    row: u32,
595    radius: u32,
596) -> Result<u64, TileError> {
597    prefetch_tiles_inner(doc, page_index, opts, tile_size, col, row, radius, None)
598}
599
600/// [`prefetch_tiles`] with a cooperative [`TileCancelToken`] (#691 slice 3).
601///
602/// Cancelling the token stops the background sweep at its next checkpoint:
603/// before each remaining tile, and inside an in-flight tile before each
604/// internal cache tile. Tiles already composited stay in the cache (they are
605/// complete and byte-correct); tiles not yet started are skipped. The
606/// returned schedule count is the same as [`prefetch_tiles`] — cancellation
607/// bounds how much of the schedule actually runs.
608///
609/// # Errors
610///
611/// Same as [`prefetch_tiles`], plus [`TileError::Cancelled`] when the token
612/// is already cancelled at call time (nothing is scheduled).
613#[cfg(all(feature = "std", feature = "parallel"))]
614#[allow(clippy::too_many_arguments)]
615pub fn prefetch_tiles_cancellable(
616    doc: &std::sync::Arc<crate::djvu_document::DjVuDocument>,
617    page_index: usize,
618    opts: &RenderOptions,
619    tile_size: u32,
620    col: u32,
621    row: u32,
622    radius: u32,
623    cancel: &TileCancelToken,
624) -> Result<u64, TileError> {
625    if cancel.is_cancelled() {
626        return Err(TileError::Cancelled);
627    }
628    prefetch_tiles_inner(
629        doc,
630        page_index,
631        opts,
632        tile_size,
633        col,
634        row,
635        radius,
636        Some(cancel.clone()),
637    )
638}
639
640#[cfg(all(feature = "std", feature = "parallel"))]
641#[allow(clippy::too_many_arguments)]
642fn prefetch_tiles_inner(
643    doc: &std::sync::Arc<crate::djvu_document::DjVuDocument>,
644    page_index: usize,
645    opts: &RenderOptions,
646    tile_size: u32,
647    col: u32,
648    row: u32,
649    radius: u32,
650    cancel: Option<TileCancelToken>,
651) -> Result<u64, TileError> {
652    let Ok(page) = doc.page(page_index) else {
653        return Ok(0);
654    };
655    let layout = TileLayout::new(page, opts, tile_size)?;
656    layout.tile_rect(col, row)?;
657    let c0 = col.saturating_sub(radius);
658    let c1 = col.saturating_add(radius).min(layout.cols() - 1);
659    let r0 = row.saturating_sub(radius);
660    let r1 = row.saturating_add(radius).min(layout.rows() - 1);
661    let scheduled = u64::from(c1 - c0 + 1) * u64::from(r1 - r0 + 1);
662    let doc = std::sync::Arc::clone(doc);
663    let opts = opts.clone();
664    rayon::spawn(move || {
665        let Ok(page) = doc.page(page_index) else {
666            return;
667        };
668        let controls = TileRenderControls {
669            quality_step: None,
670            cancel,
671            use_cache: true,
672        };
673        for r in r0..=r1 {
674            for c in c0..=c1 {
675                if controls
676                    .cancel
677                    .as_ref()
678                    .is_some_and(TileCancelToken::is_cancelled)
679                {
680                    return;
681                }
682                let _ = render_tile_with(page, &opts, tile_size, c, r, &controls);
683            }
684        }
685    });
686    Ok(scheduled)
687}
688
689#[cfg(all(test, feature = "std"))]
690mod tests {
691    use super::*;
692    use crate::djvu_document::DjVuDocument;
693    use crate::djvu_render::{
694        UserRotation, progressive_steps, render_pixmap, render_progressive_step,
695    };
696
697    fn assets_path() -> std::path::PathBuf {
698        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
699            .join("references/djvujs/library/assets")
700    }
701
702    fn load_doc(filename: &str) -> DjVuDocument {
703        let data = std::fs::read(assets_path().join(filename))
704            .unwrap_or_else(|_| panic!("{filename} must exist"));
705        DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"))
706    }
707
708    /// Blit every tile of `layout` into one display-canvas pixmap.
709    fn assemble<F>(layout: &TileLayout, mut tile: F) -> Pixmap
710    where
711        F: FnMut(u32, u32) -> Pixmap,
712    {
713        let mut out = Pixmap::white(layout.output_width(), layout.output_height());
714        let stride = layout.output_width() as usize * 4;
715        for row in 0..layout.rows() {
716            for col in 0..layout.cols() {
717                let rect = layout.tile_rect(col, row).unwrap();
718                let pm = tile(col, row);
719                assert_eq!((pm.width, pm.height), (rect.width, rect.height));
720                for y in 0..rect.height as usize {
721                    let src = y * rect.width as usize * 4;
722                    let dst = (rect.y as usize + y) * stride + rect.x as usize * 4;
723                    out.data[dst..dst + rect.width as usize * 4]
724                        .copy_from_slice(&pm.data[src..src + rect.width as usize * 4]);
725                }
726            }
727        }
728        out
729    }
730
731    /// Assembled tiles are byte-identical to `render_pixmap` for every user
732    /// rotation, on a layered (IW44 + JB2) page at a scaled size.
733    #[test]
734    fn assembled_tiles_match_full_render_all_rotations() {
735        let doc = load_doc("chicken.djvu");
736        let page = doc.page(0).unwrap();
737        for rotation in [
738            UserRotation::None,
739            UserRotation::Cw90,
740            UserRotation::Rot180,
741            UserRotation::Ccw90,
742        ] {
743            let opts = RenderOptions {
744                width: 61, // deliberately not tile-size aligned
745                height: 83,
746                rotation,
747                ..Default::default()
748            };
749            let full = render_pixmap(page, &opts).unwrap();
750            let layout = TileLayout::new(page, &opts, 32).unwrap();
751            assert_eq!(
752                (full.width, full.height),
753                (layout.output_width(), layout.output_height())
754            );
755            let stitched = assemble(&layout, |c, r| render_tile(page, &opts, 32, c, r).unwrap());
756            assert_eq!(
757                full.data, stitched.data,
758                "stitched tiles must be byte-identical to the full render (rotation {rotation:?})"
759            );
760        }
761    }
762
763    /// Same parity on a bilevel page whose INFO chunk itself carries a
764    /// rotation, combined with a user rotation.
765    #[test]
766    fn assembled_tiles_match_full_render_info_rotation() {
767        let doc = load_doc("boy_jb2_rotate90.djvu");
768        let page = doc.page(0).unwrap();
769        let opts = RenderOptions {
770            width: 45,
771            height: 57,
772            rotation: UserRotation::Cw90,
773            ..Default::default()
774        };
775        let full = render_pixmap(page, &opts).unwrap();
776        let layout = TileLayout::new(page, &opts, 16).unwrap();
777        let stitched = assemble(&layout, |c, r| render_tile(page, &opts, 16, c, r).unwrap());
778        assert_eq!(full.data, stitched.data);
779    }
780
781    /// Parity holds on a pure-bilevel page at a downscale that activates the
782    /// 1/4-resolution mask fast path in the full-page renderer.
783    #[test]
784    fn assembled_tiles_match_full_render_bilevel_downscale() {
785        let doc = load_doc("boy_jb2.djvu");
786        let page = doc.page(0).unwrap();
787        let opts = RenderOptions {
788            width: 40,
789            height: 52,
790            ..Default::default()
791        };
792        let full = render_pixmap(page, &opts).unwrap();
793        let layout = TileLayout::new(page, &opts, 16).unwrap();
794        let stitched = assemble(&layout, |c, r| render_tile(page, &opts, 16, c, r).unwrap());
795        assert_eq!(full.data, stitched.data);
796    }
797
798    /// Request order never changes pixels: forward, reverse, and cached
799    /// renders of every tile agree byte-for-byte.
800    #[test]
801    fn tile_pixels_independent_of_request_order() {
802        let doc = load_doc("chicken.djvu");
803        let page = doc.page(0).unwrap();
804        let opts = RenderOptions {
805            width: 61,
806            height: 83,
807            ..Default::default()
808        };
809        let layout = TileLayout::new(page, &opts, 32).unwrap();
810        let coords: Vec<(u32, u32)> = (0..layout.rows())
811            .flat_map(|r| (0..layout.cols()).map(move |c| (c, r)))
812            .collect();
813
814        // Uncached forward pass is the reference.
815        let reference: Vec<Pixmap> = coords
816            .iter()
817            .map(|&(c, r)| render_tile(page, &opts, 32, c, r).unwrap())
818            .collect();
819
820        // Cached, in reverse order (cold cache → misses in reverse).
821        for (i, &(c, r)) in coords.iter().enumerate().rev() {
822            let pm = render_tile_cached(page, &opts, 32, c, r).unwrap();
823            assert_eq!(pm.data, reference[i].data, "reverse pass, tile ({c}, {r})");
824        }
825        // Cached again, forward (warm cache → hits) — still identical.
826        for (i, &(c, r)) in coords.iter().enumerate() {
827            let pm = render_tile_cached(page, &opts, 32, c, r).unwrap();
828            assert_eq!(pm.data, reference[i].data, "warm pass, tile ({c}, {r})");
829        }
830    }
831
832    /// A tile size covering the whole canvas yields exactly the full render.
833    #[test]
834    fn single_tile_equals_full_render() {
835        let doc = load_doc("boy.djvu");
836        let page = doc.page(0).unwrap();
837        let opts = RenderOptions {
838            width: 50,
839            height: 70,
840            ..Default::default()
841        };
842        let layout = TileLayout::new(page, &opts, 1024).unwrap();
843        assert_eq!((layout.cols(), layout.rows()), (1, 1));
844        let tile = render_tile(page, &opts, 1024, 0, 0).unwrap();
845        let full = render_pixmap(page, &opts).unwrap();
846        assert_eq!(tile.data, full.data);
847    }
848
849    /// Grid geometry: counts, clipped edge tiles, out-of-range coordinates.
850    #[test]
851    fn layout_geometry_and_errors() {
852        let doc = load_doc("chicken.djvu");
853        let page = doc.page(0).unwrap();
854        let opts = RenderOptions {
855            width: 100,
856            height: 65,
857            ..Default::default()
858        };
859        let layout = TileLayout::new(page, &opts, 32).unwrap();
860        assert_eq!((layout.cols(), layout.rows()), (4, 3));
861        assert_eq!(layout.tile_count(), 12);
862        // Interior tile is full-size; edge tiles are clipped, not padded.
863        assert_eq!(
864            layout.tile_rect(0, 0).unwrap(),
865            TileRect {
866                x: 0,
867                y: 0,
868                width: 32,
869                height: 32
870            }
871        );
872        assert_eq!(
873            layout.tile_rect(3, 2).unwrap(),
874            TileRect {
875                x: 96,
876                y: 64,
877                width: 4,
878                height: 1
879            }
880        );
881        assert!(matches!(
882            layout.tile_rect(4, 0),
883            Err(TileError::OutOfRange {
884                col: 4,
885                row: 0,
886                cols: 4,
887                rows: 3
888            })
889        ));
890        assert!(matches!(
891            render_tile(page, &opts, 32, 0, 3),
892            Err(TileError::OutOfRange { .. })
893        ));
894
895        assert!(matches!(
896            TileLayout::new(page, &opts, 0),
897            Err(TileError::InvalidTileSize)
898        ));
899
900        let lanczos = RenderOptions {
901            resampling: Resampling::Lanczos3,
902            ..opts
903        };
904        assert!(matches!(
905            TileLayout::new(page, &lanczos, 32),
906            Err(TileError::Render(RenderError::UnsupportedOption(_)))
907        ));
908        let aa = RenderOptions { aa: true, ..opts };
909        assert!(matches!(
910            TileLayout::new(page, &aa, 32),
911            Err(TileError::Render(RenderError::UnsupportedOption(_)))
912        ));
913    }
914
915    /// 90° rotations swap the display canvas relative to `opts.width/height`.
916    #[test]
917    fn rotated_layout_swaps_display_dimensions() {
918        let doc = load_doc("chicken.djvu");
919        let page = doc.page(0).unwrap();
920        let opts = RenderOptions {
921            width: 80,
922            height: 60,
923            rotation: UserRotation::Cw90,
924            ..Default::default()
925        };
926        let layout = TileLayout::new(page, &opts, 32).unwrap();
927        assert_eq!(
928            (layout.output_width(), layout.output_height()),
929            (60, 80),
930            "Cw90 display canvas must be opts.height × opts.width"
931        );
932    }
933
934    /// Warm every cached tile of the `opts`-sized render of `page` via the
935    /// caller grid that matches the internal 256-px cache granularity.
936    fn warm_grid(page: &DjVuPage, opts: &RenderOptions) {
937        let layout = TileLayout::new(page, opts, 256).unwrap();
938        for row in 0..layout.rows() {
939            for col in 0..layout.cols() {
940                render_tile_cached(page, opts, 256, col, row).unwrap();
941            }
942        }
943    }
944
945    /// Usage reporting, budget enforcement (including shrink-on-set and
946    /// budget 0 = caching off), and clear-with-budget-preserved semantics.
947    #[test]
948    fn cache_usage_budget_and_clear() {
949        let doc = load_doc("chicken.djvu");
950        let page = doc.page(0).unwrap();
951        let opts = RenderOptions {
952            width: 600,
953            height: 800,
954            ..Default::default()
955        };
956
957        let fresh = tile_cache_usage(page);
958        assert_eq!((fresh.bytes, fresh.tiles), (0, 0));
959        assert_eq!(fresh.budget, 8 * 1024 * 1024, "default budget is 8 MiB");
960
961        warm_grid(page, &opts);
962        let warm = tile_cache_usage(page);
963        assert!(warm.bytes > 0 && warm.tiles > 0);
964
965        // Shrinking the budget below current usage evicts immediately.
966        set_tile_cache_budget(page, 300_000);
967        let shrunk = tile_cache_usage(page);
968        assert!(shrunk.bytes <= 300_000, "usage {} > budget", shrunk.bytes);
969        assert!(shrunk.tiles < warm.tiles);
970        assert_eq!(shrunk.budget, 300_000);
971
972        // Under a tiny budget rendering stays byte-correct, just cold.
973        let cached = render_tile_cached(page, &opts, 256, 0, 0).unwrap();
974        let direct = render_tile(page, &opts, 256, 0, 0).unwrap();
975        assert_eq!(cached.data, direct.data);
976        assert!(tile_cache_usage(page).bytes <= 300_000);
977
978        // Budget 0 disables caching entirely; correctness is unaffected.
979        set_tile_cache_budget(page, 0);
980        let cached = render_tile_cached(page, &opts, 256, 1, 1).unwrap();
981        let direct = render_tile(page, &opts, 256, 1, 1).unwrap();
982        assert_eq!(cached.data, direct.data);
983        assert_eq!(tile_cache_usage(page).bytes, 0);
984
985        // clear_tile_cache reports the bytes it freed and keeps the budget.
986        set_tile_cache_budget(page, 8 * 1024 * 1024);
987        warm_grid(page, &opts);
988        let before = tile_cache_usage(page);
989        let freed = clear_tile_cache(page);
990        assert_eq!(freed, before.bytes);
991        let after = tile_cache_usage(page);
992        assert_eq!((after.bytes, after.tiles), (0, 0));
993        assert_eq!(after.budget, 8 * 1024 * 1024);
994    }
995
996    /// Region invalidation drops exactly the overlapping tiles — across all
997    /// cached render sizes — and re-rendering restores identical bytes.
998    #[test]
999    fn invalidate_region_drops_overlapping_tiles_across_scales() {
1000        let doc = load_doc("chicken.djvu");
1001        let page = doc.page(0).unwrap();
1002        let big = RenderOptions {
1003            width: 600,
1004            height: 800,
1005            ..Default::default()
1006        };
1007        let small = RenderOptions {
1008            width: 300,
1009            height: 400,
1010            ..Default::default()
1011        };
1012        warm_grid(page, &big); // internal tiles: 3 cols × 4 rows = 12
1013        warm_grid(page, &small); // internal tiles: 2 cols × 2 rows = 4
1014        let reference = render_tile_cached(page, &big, 256, 0, 0).unwrap();
1015        let before = tile_cache_usage(page);
1016        assert_eq!(before.tiles, 16);
1017
1018        // Left half of the 600-wide display canvas. At 600: x < 300 drops
1019        // columns 0 and 256, keeps 512 (4 tiles survive). At 300 the rect
1020        // scales to x < 150: drops column 0, keeps 256 (2 tiles survive).
1021        let freed = invalidate_tile_region(
1022            page,
1023            &big,
1024            TileRect {
1025                x: 0,
1026                y: 0,
1027                width: 300,
1028                height: 800,
1029            },
1030        )
1031        .unwrap();
1032        assert!(freed > 0);
1033        let after = tile_cache_usage(page);
1034        assert_eq!(after.tiles, 6, "only right-column tiles survive");
1035        assert_eq!(after.bytes, before.bytes - freed);
1036
1037        // Re-rendering a dropped tile reproduces the original bytes.
1038        let rerendered = render_tile_cached(page, &big, 256, 0, 0).unwrap();
1039        assert_eq!(rerendered.data, reference.data);
1040
1041        // A region wholly outside every cached tile frees nothing.
1042        assert_eq!(
1043            invalidate_tile_region(
1044                page,
1045                &big,
1046                TileRect {
1047                    x: 599,
1048                    y: 799,
1049                    width: 0,
1050                    height: 0,
1051                },
1052            )
1053            .unwrap(),
1054            0
1055        );
1056    }
1057
1058    /// A display-space region under a rotated view invalidates the same
1059    /// pre-rotation tiles as the equivalent unrotated region.
1060    #[test]
1061    fn invalidate_maps_display_rect_through_rotation() {
1062        let identity = RenderOptions {
1063            width: 600,
1064            height: 800,
1065            ..Default::default()
1066        };
1067        let rotated = RenderOptions {
1068            rotation: UserRotation::Cw90,
1069            ..identity.clone()
1070        };
1071
1072        // Two identically warmed caches (separate documents = separate caches).
1073        let doc_a = load_doc("chicken.djvu");
1074        let page_a = doc_a.page(0).unwrap();
1075        warm_grid(page_a, &identity);
1076        let doc_b = load_doc("chicken.djvu");
1077        let page_b = doc_b.page(0).unwrap();
1078        warm_grid(page_b, &identity);
1079
1080        // Under Cw90 the display canvas is 800×600 and its top strip
1081        // `{0, 0, 800, 300}` pulls back to the pre-rotation left strip
1082        // `{0, 0, 300, 800}` — the same region as the identity-space rect.
1083        let freed_identity = invalidate_tile_region(
1084            page_a,
1085            &identity,
1086            TileRect {
1087                x: 0,
1088                y: 0,
1089                width: 300,
1090                height: 800,
1091            },
1092        )
1093        .unwrap();
1094        let freed_rotated = invalidate_tile_region(
1095            page_b,
1096            &rotated,
1097            TileRect {
1098                x: 0,
1099                y: 0,
1100                width: 800,
1101                height: 300,
1102            },
1103        )
1104        .unwrap();
1105        assert!(freed_identity > 0);
1106        assert_eq!(freed_identity, freed_rotated);
1107        assert_eq!(
1108            tile_cache_usage(page_a).tiles,
1109            tile_cache_usage(page_b).tiles
1110        );
1111    }
1112
1113    /// Prefetch warms the same cache `render_tile_cached` reads, without
1114    /// changing a byte of output; out-of-range pages are a no-op.
1115    #[cfg(feature = "parallel")]
1116    #[test]
1117    fn prefetch_tiles_warms_cache() {
1118        let doc = std::sync::Arc::new(load_doc("chicken.djvu"));
1119        let page = doc.page(0).unwrap();
1120        let opts = RenderOptions {
1121            width: 600,
1122            height: 800,
1123            ..Default::default()
1124        };
1125
1126        assert_eq!(prefetch_tiles(&doc, 99, &opts, 256, 0, 0, 1).unwrap(), 0);
1127        assert!(matches!(
1128            prefetch_tiles(&doc, 0, &opts, 256, 99, 0, 1),
1129            Err(TileError::OutOfRange { .. })
1130        ));
1131
1132        // radius 1 around (0,0) on a 3×4 grid clips to a 2×2 neighborhood.
1133        let scheduled = prefetch_tiles(&doc, 0, &opts, 256, 0, 0, 1).unwrap();
1134        assert_eq!(scheduled, 4);
1135
1136        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
1137        while tile_cache_usage(page).tiles < 4 && std::time::Instant::now() < deadline {
1138            std::thread::sleep(std::time::Duration::from_millis(10));
1139        }
1140        assert!(
1141            tile_cache_usage(page).tiles >= 4,
1142            "prefetch never landed: {:?}",
1143            tile_cache_usage(page)
1144        );
1145        let warm = render_tile_cached(page, &opts, 256, 0, 0).unwrap();
1146        let direct = render_tile(page, &opts, 256, 0, 0).unwrap();
1147        assert_eq!(warm.data, direct.data);
1148    }
1149
1150    /// At every progressive quality step, assembled tiles are byte-identical
1151    /// to the full `render_progressive_step` frame; steps actually refine.
1152    #[test]
1153    fn progressive_tiles_match_progressive_frames() {
1154        let doc = load_doc("chicken.djvu");
1155        let page = doc.page(0).unwrap();
1156        let steps = progressive_steps(page);
1157        assert!(steps >= 2, "need a multi-chunk BG44 page");
1158        let opts = RenderOptions {
1159            width: 61,
1160            height: 83,
1161            ..Default::default()
1162        };
1163        let layout = TileLayout::new(page, &opts, 32).unwrap();
1164        let mut frames = Vec::new();
1165        for step in 0..steps {
1166            let full = render_progressive_step(page, &opts, step).unwrap();
1167            let controls = TileRenderControls {
1168                quality_step: Some(step),
1169                ..Default::default()
1170            };
1171            let stitched = assemble(&layout, |c, r| {
1172                render_tile_with(page, &opts, 32, c, r, &controls).unwrap()
1173            });
1174            assert_eq!(
1175                full.data, stitched.data,
1176                "stitched step-{step} tiles must match the progressive frame"
1177            );
1178            frames.push(full.data);
1179        }
1180        assert_ne!(
1181            frames[0],
1182            frames[steps - 1],
1183            "the first and last quality steps must differ (refinement adds detail)"
1184        );
1185    }
1186
1187    /// Progressive tiles honor the rotation pull-back exactly like full
1188    /// quality tiles: stitched step-0 tiles match the rotated frame.
1189    #[test]
1190    fn progressive_tiles_match_under_rotation() {
1191        let doc = load_doc("chicken.djvu");
1192        let page = doc.page(0).unwrap();
1193        let opts = RenderOptions {
1194            width: 61,
1195            height: 83,
1196            rotation: UserRotation::Cw90,
1197            ..Default::default()
1198        };
1199        let full = render_progressive_step(page, &opts, 0).unwrap();
1200        let layout = TileLayout::new(page, &opts, 32).unwrap();
1201        let controls = TileRenderControls {
1202            quality_step: Some(0),
1203            ..Default::default()
1204        };
1205        let stitched = assemble(&layout, |c, r| {
1206            render_tile_with(page, &opts, 32, c, r, &controls).unwrap()
1207        });
1208        assert_eq!(full.data, stitched.data);
1209    }
1210
1211    /// `render_tile_with` reproduces the dedicated entry points byte-for-byte
1212    /// in its default and cache-assembly modes.
1213    #[test]
1214    fn render_tile_with_matches_dedicated_entry_points() {
1215        let doc = load_doc("chicken.djvu");
1216        let page = doc.page(0).unwrap();
1217        let opts = RenderOptions {
1218            width: 61,
1219            height: 83,
1220            ..Default::default()
1221        };
1222        let direct = render_tile(page, &opts, 32, 1, 1).unwrap();
1223
1224        let default = render_tile_with(page, &opts, 32, 1, 1, &TileRenderControls::default());
1225        assert_eq!(default.unwrap().data, direct.data);
1226
1227        let cached = render_tile_with(
1228            page,
1229            &opts,
1230            32,
1231            1,
1232            1,
1233            &TileRenderControls {
1234                use_cache: true,
1235                ..Default::default()
1236            },
1237        );
1238        assert_eq!(cached.unwrap().data, direct.data);
1239    }
1240
1241    /// On a page without BG44 background data the quality ladder has exactly
1242    /// one step: step 0 is the full render, step 1 is out of range.
1243    #[test]
1244    fn quality_steps_on_bilevel_page() {
1245        let doc = load_doc("boy_jb2.djvu");
1246        let page = doc.page(0).unwrap();
1247        assert_eq!(progressive_steps(page), 1);
1248        let opts = RenderOptions {
1249            width: 40,
1250            height: 52,
1251            ..Default::default()
1252        };
1253        let direct = render_tile(page, &opts, 16, 0, 0).unwrap();
1254        let step0 = render_tile_with(
1255            page,
1256            &opts,
1257            16,
1258            0,
1259            0,
1260            &TileRenderControls {
1261                quality_step: Some(0),
1262                ..Default::default()
1263            },
1264        )
1265        .unwrap();
1266        assert_eq!(step0.data, direct.data);
1267
1268        assert!(matches!(
1269            render_tile_with(
1270                page,
1271                &opts,
1272                16,
1273                0,
1274                0,
1275                &TileRenderControls {
1276                    quality_step: Some(1),
1277                    ..Default::default()
1278                },
1279            ),
1280            Err(TileError::Render(RenderError::ChunkOutOfRange {
1281                chunk_n: 1,
1282                max: 0
1283            }))
1284        ));
1285    }
1286
1287    /// A cancelled token aborts every mode with `TileError::Cancelled`; a
1288    /// live token changes nothing about the rendered bytes.
1289    #[test]
1290    fn cancelled_token_aborts_every_mode() {
1291        let doc = load_doc("chicken.djvu");
1292        let page = doc.page(0).unwrap();
1293        let opts = RenderOptions {
1294            width: 61,
1295            height: 83,
1296            ..Default::default()
1297        };
1298
1299        let token = TileCancelToken::new();
1300        assert!(!token.is_cancelled());
1301        let shared = token.clone();
1302        shared.cancel();
1303        assert!(token.is_cancelled(), "clones share one flag");
1304
1305        for controls in [
1306            TileRenderControls {
1307                cancel: Some(token.clone()),
1308                ..Default::default()
1309            },
1310            TileRenderControls {
1311                cancel: Some(token.clone()),
1312                use_cache: true,
1313                ..Default::default()
1314            },
1315            TileRenderControls {
1316                cancel: Some(token.clone()),
1317                quality_step: Some(0),
1318                ..Default::default()
1319            },
1320        ] {
1321            assert!(matches!(
1322                render_tile_with(page, &opts, 32, 0, 0, &controls),
1323                Err(TileError::Cancelled)
1324            ));
1325        }
1326
1327        // A live token leaves output byte-identical to the plain call.
1328        let live = TileCancelToken::new();
1329        let with_token = render_tile_with(
1330            page,
1331            &opts,
1332            32,
1333            0,
1334            0,
1335            &TileRenderControls {
1336                cancel: Some(live),
1337                ..Default::default()
1338            },
1339        )
1340        .unwrap();
1341        let direct = render_tile(page, &opts, 32, 0, 0).unwrap();
1342        assert_eq!(with_token.data, direct.data);
1343    }
1344
1345    /// Cancellable prefetch: an already-cancelled token schedules nothing;
1346    /// a live token warms the cache exactly like `prefetch_tiles`.
1347    #[cfg(feature = "parallel")]
1348    #[test]
1349    fn prefetch_tiles_cancellable_behaviour() {
1350        let doc = std::sync::Arc::new(load_doc("chicken.djvu"));
1351        let page = doc.page(0).unwrap();
1352        let opts = RenderOptions {
1353            width: 600,
1354            height: 800,
1355            ..Default::default()
1356        };
1357
1358        let cancelled = TileCancelToken::new();
1359        cancelled.cancel();
1360        assert!(matches!(
1361            prefetch_tiles_cancellable(&doc, 0, &opts, 256, 0, 0, 1, &cancelled),
1362            Err(TileError::Cancelled)
1363        ));
1364        assert_eq!(
1365            tile_cache_usage(page).tiles,
1366            0,
1367            "a pre-cancelled prefetch must not warm anything"
1368        );
1369
1370        let live = TileCancelToken::new();
1371        let scheduled = prefetch_tiles_cancellable(&doc, 0, &opts, 256, 0, 0, 1, &live).unwrap();
1372        assert_eq!(scheduled, 4);
1373        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
1374        while tile_cache_usage(page).tiles < 4 && std::time::Instant::now() < deadline {
1375            std::thread::sleep(std::time::Duration::from_millis(10));
1376        }
1377        assert!(tile_cache_usage(page).tiles >= 4);
1378        let warm = render_tile_cached(page, &opts, 256, 0, 0).unwrap();
1379        let direct = render_tile(page, &opts, 256, 0, 0).unwrap();
1380        assert_eq!(warm.data, direct.data);
1381    }
1382}