#[cfg(not(feature = "std"))]
use alloc::{vec, vec::Vec};
use crate::djvu_document::DjVuPage;
use crate::iw44_new::Iw44Image;
use crate::jb2_new;
use crate::pixmap::{GrayPixmap, Pixmap};
#[derive(Debug, thiserror::Error)]
pub enum RenderError {
#[error("IW44 decode error: {0}")]
Iw44(#[from] crate::error::Iw44Error),
#[error("JB2 decode error: {0}")]
Jb2(#[from] crate::error::Jb2Error),
#[error("buffer too small: need {need} bytes, got {got}")]
BufTooSmall { need: usize, got: usize },
#[error("invalid render dimensions: {width}x{height}")]
InvalidDimensions { width: u32, height: u32 },
#[error("chunk index {chunk_n} out of range (max {max})")]
ChunkOutOfRange { chunk_n: usize, max: usize },
#[error("BZZ error: {0}")]
Bzz(#[from] crate::error::BzzError),
#[cfg(feature = "std")]
#[error("JPEG decode error: {0}")]
Jpeg(String),
#[error("document error: {0}")]
Doc(#[from] crate::djvu_document::DocError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UserRotation {
#[default]
None,
Cw90,
Rot180,
Ccw90,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Resampling {
#[default]
Bilinear,
Lanczos3,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RenderOptions {
pub width: u32,
pub height: u32,
pub scale: f32,
pub bold: u8,
pub aa: bool,
pub rotation: UserRotation,
pub permissive: bool,
pub resampling: Resampling,
}
impl Default for RenderOptions {
fn default() -> Self {
RenderOptions {
width: 0,
height: 0,
scale: 1.0,
bold: 0,
aa: false,
rotation: UserRotation::None,
permissive: false,
resampling: Resampling::Bilinear,
}
}
}
impl RenderOptions {
pub fn fit_to_width(page: &crate::djvu_document::DjVuPage, width: u32) -> Self {
let (dw, dh) = display_dimensions(page);
let height = if dw == 0 {
width
} else {
((dh as f64 * width as f64) / dw as f64).round() as u32
}
.max(1);
let scale = width as f32 / dw.max(1) as f32;
RenderOptions {
width,
height,
scale,
..Default::default()
}
}
pub fn fit_to_height(page: &crate::djvu_document::DjVuPage, height: u32) -> Self {
let (dw, dh) = display_dimensions(page);
let width = if dh == 0 {
height
} else {
((dw as f64 * height as f64) / dh as f64).round() as u32
}
.max(1);
let scale = height as f32 / dh.max(1) as f32;
RenderOptions {
width,
height,
scale,
..Default::default()
}
}
pub fn fit_to_box(
page: &crate::djvu_document::DjVuPage,
max_width: u32,
max_height: u32,
) -> Self {
let (dw, dh) = display_dimensions(page);
if dw == 0 || dh == 0 {
return RenderOptions {
width: max_width.max(1),
height: max_height.max(1),
scale: 1.0,
..Default::default()
};
}
let scale_w = max_width as f64 / dw as f64;
let scale_h = max_height as f64 / dh as f64;
let scale = if scale_w < scale_h { scale_w } else { scale_h };
let width = (dw as f64 * scale).round() as u32;
let height = (dh as f64 * scale).round() as u32;
RenderOptions {
width: width.max(1),
height: height.max(1),
scale: scale as f32,
..Default::default()
}
}
}
fn display_dimensions(page: &crate::djvu_document::DjVuPage) -> (u32, u32) {
let w = page.width() as u32;
let h = page.height() as u32;
match page.rotation() {
crate::info::Rotation::Cw90 | crate::info::Rotation::Ccw90 => (h, w),
_ => (w, h),
}
}
fn build_gamma_lut(gamma: f32) -> [u8; 256] {
let mut lut = [0u8; 256];
if gamma <= 0.0 || !gamma.is_finite() || (gamma - 1.0).abs() < 1e-4 {
for (i, v) in lut.iter_mut().enumerate() {
*v = i as u8;
}
return lut;
}
let inv_gamma = 1.0 / gamma;
for (i, v) in lut.iter_mut().enumerate() {
let linear = i as f32 / 255.0;
let corrected = linear.powf(inv_gamma);
*v = (corrected * 255.0 + 0.5) as u8;
}
lut
}
const FRACBITS: u32 = 4;
const FRAC: u32 = 1 << FRACBITS;
const FRAC_MASK: u32 = FRAC - 1;
#[inline]
fn sample_bilinear(pm: &Pixmap, fx: u32, fy: u32) -> (u8, u8, u8) {
let x0 = (fx >> FRACBITS).min(pm.width.saturating_sub(1));
let y0 = (fy >> FRACBITS).min(pm.height.saturating_sub(1));
let x1 = (x0 + 1).min(pm.width.saturating_sub(1));
let y1 = (y0 + 1).min(pm.height.saturating_sub(1));
let tx = fx & FRAC_MASK; let ty = fy & FRAC_MASK;
let (r00, g00, b00) = pm.get_rgb(x0, y0);
let (r10, g10, b10) = pm.get_rgb(x1, y0);
let (r01, g01, b01) = pm.get_rgb(x0, y1);
let (r11, g11, b11) = pm.get_rgb(x1, y1);
let lerp = |a: u8, b: u8, c: u8, d: u8| -> u8 {
let top = a as u32 * (FRAC - tx) + b as u32 * tx;
let bot = c as u32 * (FRAC - tx) + d as u32 * tx;
let v = (top * (FRAC - ty) + bot * ty) >> (2 * FRACBITS);
v.min(255) as u8
};
(
lerp(r00, r10, r01, r11),
lerp(g00, g10, g01, g11),
lerp(b00, b10, b01, b11),
)
}
#[inline]
fn sample_area_avg(pm: &Pixmap, fx: u32, fy: u32, fx_step: u32, fy_step: u32) -> (u8, u8, u8) {
let x0 = (fx >> FRACBITS).min(pm.width.saturating_sub(1));
let y0 = (fy >> FRACBITS).min(pm.height.saturating_sub(1));
let x1 = ((fx + fx_step) >> FRACBITS).min(pm.width.saturating_sub(1));
let y1 = ((fy + fy_step) >> FRACBITS).min(pm.height.saturating_sub(1));
if x0 == x1 && y0 == y1 {
return pm.get_rgb(x0, y0);
}
let mut r_sum = 0u32;
let mut g_sum = 0u32;
let mut b_sum = 0u32;
let pw = pm.width as usize;
let cols = (x1 - x0 + 1) as usize;
let rows = (y1 - y0 + 1) as usize;
for sy in y0..=y1 {
let row_off = (sy as usize * pw + x0 as usize) * 4;
for c in 0..cols {
let off = row_off + c * 4;
if let Some(px) = pm.data.get(off..off + 3) {
r_sum += px[0] as u32;
g_sum += px[1] as u32;
b_sum += px[2] as u32;
}
}
}
let count = (rows * cols) as u32;
if count == 0 {
return (255, 255, 255);
}
(
((r_sum + count / 2) / count) as u8,
((g_sum + count / 2) / count) as u8,
((b_sum + count / 2) / count) as u8,
)
}
#[inline]
fn lanczos3_kernel(x: f32) -> f32 {
let ax = x.abs();
if ax >= 3.0 {
return 0.0;
}
if ax < 1e-6 {
return 1.0;
}
let pi_x = core::f32::consts::PI * ax;
let sinc_x = pi_x.sin() / pi_x;
let pi_x3 = pi_x / 3.0;
let sinc_x3 = pi_x3.sin() / pi_x3;
sinc_x * sinc_x3
}
pub fn scale_lanczos3(src: &Pixmap, dst_w: u32, dst_h: u32) -> Pixmap {
let src_w = src.width;
let src_h = src.height;
if src_w == dst_w && src_h == dst_h {
return src.clone();
}
if dst_w == 0 || dst_h == 0 {
return Pixmap::white(dst_w.max(1), dst_h.max(1));
}
let h_scale = src_w as f32 / dst_w as f32;
let h_support = (3.0_f32 * h_scale.max(1.0)).ceil() as i32;
let mut mid = Pixmap::new(dst_w, src_h, 255, 255, 255, 255);
for oy in 0..src_h {
for ox in 0..dst_w {
let cx = (ox as f32 + 0.5) * h_scale - 0.5;
let x0 = (cx.floor() as i32 - h_support + 1).max(0);
let x1 = (cx.floor() as i32 + h_support).min(src_w as i32 - 1);
let mut r = 0.0_f32;
let mut g = 0.0_f32;
let mut b = 0.0_f32;
let mut w_sum = 0.0_f32;
for sx in x0..=x1 {
let w = lanczos3_kernel((sx as f32 - cx) / h_scale.max(1.0));
let (pr, pg, pb) = src.get_rgb(sx as u32, oy);
r += pr as f32 * w;
g += pg as f32 * w;
b += pb as f32 * w;
w_sum += w;
}
let norm = if w_sum.abs() > 1e-6 { 1.0 / w_sum } else { 1.0 };
mid.set_rgb(
ox,
oy,
(r * norm).round().clamp(0.0, 255.0) as u8,
(g * norm).round().clamp(0.0, 255.0) as u8,
(b * norm).round().clamp(0.0, 255.0) as u8,
);
}
}
let v_scale = src_h as f32 / dst_h as f32;
let v_support = (3.0_f32 * v_scale.max(1.0)).ceil() as i32;
let mut out = Pixmap::new(dst_w, dst_h, 255, 255, 255, 255);
for oy in 0..dst_h {
let cy = (oy as f32 + 0.5) * v_scale - 0.5;
let y0 = (cy.floor() as i32 - v_support + 1).max(0);
let y1 = (cy.floor() as i32 + v_support).min(src_h as i32 - 1);
for ox in 0..dst_w {
let mut r = 0.0_f32;
let mut g = 0.0_f32;
let mut b = 0.0_f32;
let mut w_sum = 0.0_f32;
for sy in y0..=y1 {
let w = lanczos3_kernel((sy as f32 - cy) / v_scale.max(1.0));
let (pr, pg, pb) = mid.get_rgb(ox, sy as u32);
r += pr as f32 * w;
g += pg as f32 * w;
b += pb as f32 * w;
w_sum += w;
}
let norm = if w_sum.abs() > 1e-6 { 1.0 / w_sum } else { 1.0 };
out.set_rgb(
ox,
oy,
(r * norm).round().clamp(0.0, 255.0) as u8,
(g * norm).round().clamp(0.0, 255.0) as u8,
(b * norm).round().clamp(0.0, 255.0) as u8,
);
}
}
out
}
#[inline]
fn mask_box_any(
mask: &crate::bitmap::Bitmap,
fx: u32,
fy: u32,
fx_step: u32,
fy_step: u32,
) -> bool {
let x0 = (fx >> FRACBITS).min(mask.width.saturating_sub(1));
let y0 = (fy >> FRACBITS).min(mask.height.saturating_sub(1));
let x1 = ((fx + fx_step) >> FRACBITS).min(mask.width.saturating_sub(1));
let y1 = ((fy + fy_step) >> FRACBITS).min(mask.height.saturating_sub(1));
for sy in y0..=y1 {
for sx in x0..=x1 {
if mask.get(sx, sy) {
return true;
}
}
}
false
}
#[inline]
fn mask_box_center_fg(
mask: &crate::bitmap::Bitmap,
fx: u32,
fy: u32,
fx_step: u32,
fy_step: u32,
) -> (u32, u32) {
let cx = (fx + fx_step / 2) >> FRACBITS;
let cy = (fy + fy_step / 2) >> FRACBITS;
(
cx.min(mask.width.saturating_sub(1)),
cy.min(mask.height.saturating_sub(1)),
)
}
fn aa_downscale(pm: &Pixmap) -> Pixmap {
let out_w = (pm.width / 2).max(1);
let out_h = (pm.height / 2).max(1);
let mut out = Pixmap::white(out_w, out_h);
for y in 0..out_h {
for x in 0..out_w {
let sx = (x * 2).min(pm.width.saturating_sub(1));
let sy = (y * 2).min(pm.height.saturating_sub(1));
let sx1 = (sx + 1).min(pm.width.saturating_sub(1));
let sy1 = (sy + 1).min(pm.height.saturating_sub(1));
let (r00, g00, b00) = pm.get_rgb(sx, sy);
let (r10, g10, b10) = pm.get_rgb(sx1, sy);
let (r01, g01, b01) = pm.get_rgb(sx, sy1);
let (r11, g11, b11) = pm.get_rgb(sx1, sy1);
let avg = |a: u8, b: u8, c: u8, d: u8| -> u8 {
((a as u32 + b as u32 + c as u32 + d as u32 + 2) / 4) as u8
};
out.set_rgb(
x,
y,
avg(r00, r10, r01, r11),
avg(g00, g10, g01, g11),
avg(b00, b10, b01, b11),
);
}
}
out
}
fn rotation_to_steps(r: crate::info::Rotation) -> u8 {
use crate::info::Rotation;
match r {
Rotation::None => 0,
Rotation::Cw90 => 1,
Rotation::Rot180 => 2,
Rotation::Ccw90 => 3,
}
}
fn user_rotation_to_steps(r: UserRotation) -> u8 {
match r {
UserRotation::None => 0,
UserRotation::Cw90 => 1,
UserRotation::Rot180 => 2,
UserRotation::Ccw90 => 3,
}
}
fn combine_rotations(info: crate::info::Rotation, user: UserRotation) -> crate::info::Rotation {
use crate::info::Rotation;
let steps = (rotation_to_steps(info) + user_rotation_to_steps(user)) % 4;
match steps {
0 => Rotation::None,
1 => Rotation::Cw90,
2 => Rotation::Rot180,
3 => Rotation::Ccw90,
_ => unreachable!(),
}
}
fn rotate_pixmap(src: Pixmap, rotation: crate::info::Rotation) -> Pixmap {
use crate::info::Rotation;
match rotation {
Rotation::None => src,
Rotation::Cw90 => {
let w = src.height;
let h = src.width;
let mut out = Pixmap::white(w, h);
for y in 0..src.height {
for x in 0..src.width {
let (r, g, b) = src.get_rgb(x, y);
out.set_rgb(src.height - 1 - y, x, r, g, b);
}
}
out
}
Rotation::Rot180 => {
let mut out = Pixmap::white(src.width, src.height);
for y in 0..src.height {
for x in 0..src.width {
let (r, g, b) = src.get_rgb(x, y);
out.set_rgb(src.width - 1 - x, src.height - 1 - y, r, g, b);
}
}
out
}
Rotation::Ccw90 => {
let w = src.height;
let h = src.width;
let mut out = Pixmap::white(w, h);
for y in 0..src.height {
for x in 0..src.width {
let (r, g, b) = src.get_rgb(x, y);
out.set_rgb(y, src.width - 1 - x, r, g, b);
}
}
out
}
}
}
#[derive(Debug, Clone, Copy, Default)]
struct PaletteColor {
r: u8,
g: u8,
b: u8,
}
struct FgbzPalette {
colors: Vec<PaletteColor>,
indices: Vec<i16>,
}
fn parse_fgbz(data: &[u8]) -> Result<FgbzPalette, RenderError> {
if data.len() < 3 {
return Ok(FgbzPalette {
colors: vec![],
indices: vec![],
});
}
let version = data[0];
let has_indices = (version & 0x80) != 0;
let n_colors =
u16::from_be_bytes([*data.get(1).unwrap_or(&0), *data.get(2).unwrap_or(&0)]) as usize;
if n_colors == 0 {
return Ok(FgbzPalette {
colors: vec![],
indices: vec![],
});
}
let color_bytes = n_colors * 3;
let color_data = data.get(3..).unwrap_or(&[]);
let mut colors = Vec::with_capacity(n_colors);
for i in 0..n_colors {
let base = i * 3;
if base + 2 < color_data.len().min(color_bytes) {
colors.push(PaletteColor {
r: color_data[base + 2],
g: color_data[base + 1],
b: color_data[base],
});
} else {
colors.push(PaletteColor { r: 0, g: 0, b: 0 });
}
}
let mut indices = Vec::new();
if has_indices {
let idx_start = 3 + color_bytes;
if idx_start + 3 <= data.len() {
let num_indices = ((data[idx_start] as u32) << 16)
| ((data[idx_start + 1] as u32) << 8)
| (data[idx_start + 2] as u32);
let bzz_data = data.get(idx_start + 3..).unwrap_or(&[]);
let decoded = crate::bzz_new::bzz_decode(bzz_data)?;
let n = num_indices as usize;
indices.reserve(n);
for i in 0..n {
if i * 2 + 1 < decoded.len() {
indices.push(i16::from_be_bytes([decoded[i * 2], decoded[i * 2 + 1]]));
}
}
}
}
Ok(FgbzPalette { colors, indices })
}
fn decode_background_chunks(
page: &DjVuPage,
max_chunks: usize,
) -> Result<Option<Pixmap>, RenderError> {
let bg44_chunks = page.bg44_chunks();
if !bg44_chunks.is_empty() {
let mut img = Iw44Image::new();
for chunk_data in bg44_chunks.iter().take(max_chunks) {
img.decode_chunk(chunk_data)?;
}
return Ok(Some(img.to_rgb()?));
}
#[cfg(feature = "std")]
if let Some(pm) = decode_bgjp(page)? {
return Ok(Some(pm));
}
Ok(None)
}
fn decode_background_chunks_permissive(page: &DjVuPage, max_chunks: usize) -> Option<Pixmap> {
let bg44_chunks = page.bg44_chunks();
if !bg44_chunks.is_empty() {
let mut img = Iw44Image::new();
for chunk_data in bg44_chunks.iter().take(max_chunks) {
if img.decode_chunk(chunk_data).is_err() {
break; }
}
return img.to_rgb().ok();
}
#[cfg(feature = "std")]
{
decode_bgjp(page).ok().flatten()
}
#[cfg(not(feature = "std"))]
None
}
fn decode_mask(page: &DjVuPage) -> Result<Option<crate::bitmap::Bitmap>, RenderError> {
let sjbz = match page.find_chunk(b"Sjbz") {
Some(data) => data,
None => return Ok(None),
};
let dict = match page.find_chunk(b"Djbz") {
Some(djbz) => Some(jb2_new::decode_dict(djbz, None)?),
None => None,
};
let bm = jb2_new::decode(sjbz, dict.as_ref())?;
Ok(Some(bm))
}
fn decode_mask_indexed(
page: &DjVuPage,
) -> Result<Option<(crate::bitmap::Bitmap, Vec<i32>)>, RenderError> {
let sjbz = match page.find_chunk(b"Sjbz") {
Some(data) => data,
None => return Ok(None),
};
let dict = match page.find_chunk(b"Djbz") {
Some(djbz) => Some(jb2_new::decode_dict(djbz, None)?),
None => None,
};
let (bm, blit_map) = jb2_new::decode_indexed(sjbz, dict.as_ref())?;
Ok(Some((bm, blit_map)))
}
fn decode_fg_palette_full(page: &DjVuPage) -> Result<Option<FgbzPalette>, RenderError> {
let fgbz = match page.find_chunk(b"FGbz") {
Some(data) => data,
None => return Ok(None),
};
let pal = parse_fgbz(fgbz)?;
if pal.colors.is_empty() {
return Ok(None);
}
Ok(Some(pal))
}
fn decode_fg44(page: &DjVuPage) -> Result<Option<Pixmap>, RenderError> {
let fg44_chunks = page.fg44_chunks();
if !fg44_chunks.is_empty() {
let mut img = Iw44Image::new();
for chunk_data in &fg44_chunks {
img.decode_chunk(chunk_data)?;
}
return Ok(Some(img.to_rgb()?));
}
#[cfg(feature = "std")]
if let Some(pm) = decode_fgjp(page)? {
return Ok(Some(pm));
}
Ok(None)
}
#[cfg(feature = "std")]
fn decode_bgjp(page: &DjVuPage) -> Result<Option<Pixmap>, RenderError> {
let data = match page.find_chunk(b"BGjp") {
Some(d) => d,
None => return Ok(None),
};
Ok(Some(decode_jpeg_to_pixmap(data)?))
}
#[cfg(feature = "std")]
fn decode_fgjp(page: &DjVuPage) -> Result<Option<Pixmap>, RenderError> {
let data = match page.find_chunk(b"FGjp") {
Some(d) => d,
None => return Ok(None),
};
Ok(Some(decode_jpeg_to_pixmap(data)?))
}
#[cfg(feature = "std")]
fn decode_jpeg_to_pixmap(data: &[u8]) -> Result<Pixmap, RenderError> {
use zune_jpeg::JpegDecoder;
use zune_jpeg::zune_core::bytestream::ZCursor;
let cursor = ZCursor::new(data);
let mut decoder = JpegDecoder::new(cursor);
decoder
.decode_headers()
.map_err(|e| RenderError::Jpeg(format!("{e:?}")))?;
let info = decoder
.info()
.ok_or_else(|| RenderError::Jpeg("missing image info after decode_headers".to_owned()))?;
let w = info.width as usize;
let h = info.height as usize;
let rgb = decoder
.decode()
.map_err(|e| RenderError::Jpeg(format!("{e:?}")))?;
let mut rgba = vec![0u8; w * h * 4];
for (i, pixel) in rgba.chunks_exact_mut(4).enumerate() {
let src = i * 3;
pixel[0] = *rgb.get(src).unwrap_or(&0);
pixel[1] = *rgb.get(src + 1).unwrap_or(&0);
pixel[2] = *rgb.get(src + 2).unwrap_or(&0);
pixel[3] = 255;
}
Ok(Pixmap {
width: w as u32,
height: h as u32,
data: rgba,
})
}
struct CompositeContext<'a> {
opts: &'a RenderOptions,
page_w: u32,
page_h: u32,
bg: Option<&'a Pixmap>,
mask: Option<&'a crate::bitmap::Bitmap>,
fg_palette: Option<&'a FgbzPalette>,
blit_map: Option<&'a [i32]>,
fg44: Option<&'a Pixmap>,
gamma_lut: &'a [u8; 256],
}
#[inline]
fn lookup_palette_color(
pal: &FgbzPalette,
blit_map: Option<&[i32]>,
mask: Option<&crate::bitmap::Bitmap>,
px: u32,
py: u32,
) -> PaletteColor {
if let Some(bm) = blit_map
&& let Some(m) = mask
{
let mi = py as usize * m.width as usize + px as usize;
if mi < bm.len() {
let blit_idx = bm[mi];
if blit_idx >= 0 {
if !pal.indices.is_empty() {
let bi = blit_idx as usize;
if bi < pal.indices.len() {
let ci = pal.indices[bi] as usize;
if ci < pal.colors.len() {
return pal.colors[ci];
}
}
} else {
let ci = blit_idx as usize;
if ci < pal.colors.len() {
return pal.colors[ci];
}
}
}
}
}
pal.colors.first().copied().unwrap_or_default()
}
#[allow(clippy::too_many_arguments)]
fn composite_loop_bilinear(
ctx: &CompositeContext<'_>,
buf: &mut [u8],
w: u32,
h: u32,
page_w: u32,
page_h: u32,
fx_step: u32,
fy_step: u32,
) {
for oy in 0..h {
let fy = oy * fy_step;
let py = (fy >> FRACBITS).min(page_h.saturating_sub(1));
let row_base = oy as usize * w as usize;
for ox in 0..w {
let fx = ox * fx_step;
let px = (fx >> FRACBITS).min(page_w.saturating_sub(1));
let is_fg = ctx
.mask
.is_some_and(|m| px < m.width && py < m.height && m.get(px, py));
let (r, g, b) = if is_fg {
if let Some(pal) = ctx.fg_palette {
let color = lookup_palette_color(pal, ctx.blit_map, ctx.mask, px, py);
(color.r, color.g, color.b)
} else if let Some(fg) = ctx.fg44 {
sample_bilinear(fg, fx, fy)
} else {
(0, 0, 0)
}
} else if let Some(bg) = ctx.bg {
sample_bilinear(bg, fx, fy)
} else {
(255, 255, 255)
};
let r = ctx.gamma_lut[r as usize];
let g = ctx.gamma_lut[g as usize];
let b = ctx.gamma_lut[b as usize];
let base = (row_base + ox as usize) * 4;
if let Some(pixel) = buf.get_mut(base..base + 4) {
pixel[0] = r;
pixel[1] = g;
pixel[2] = b;
pixel[3] = 255;
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn composite_loop_area_avg(
ctx: &CompositeContext<'_>,
buf: &mut [u8],
w: u32,
h: u32,
_page_w: u32,
_page_h: u32,
fx_step: u32,
fy_step: u32,
) {
for oy in 0..h {
let fy = oy * fy_step;
let row_base = oy as usize * w as usize;
for ox in 0..w {
let fx = ox * fx_step;
let is_fg = ctx
.mask
.is_some_and(|m| mask_box_any(m, fx, fy, fx_step, fy_step));
let (r, g, b) = if is_fg {
if let Some(pal) = ctx.fg_palette {
let (cx, cy) = mask_box_center_fg(ctx.mask.unwrap(), fx, fy, fx_step, fy_step);
let color = lookup_palette_color(pal, ctx.blit_map, ctx.mask, cx, cy);
(color.r, color.g, color.b)
} else if let Some(fg) = ctx.fg44 {
sample_area_avg(fg, fx, fy, fx_step, fy_step)
} else {
(0, 0, 0)
}
} else if let Some(bg) = ctx.bg {
sample_area_avg(bg, fx, fy, fx_step, fy_step)
} else {
(255, 255, 255)
};
let r = ctx.gamma_lut[r as usize];
let g = ctx.gamma_lut[g as usize];
let b = ctx.gamma_lut[b as usize];
let base = (row_base + ox as usize) * 4;
if let Some(pixel) = buf.get_mut(base..base + 4) {
pixel[0] = r;
pixel[1] = g;
pixel[2] = b;
pixel[3] = 255;
}
}
}
}
fn composite_into(ctx: &CompositeContext<'_>, buf: &mut [u8]) -> Result<(), RenderError> {
let w = ctx.opts.width;
let h = ctx.opts.height;
let page_w = ctx.page_w;
let page_h = ctx.page_h;
let fx_step = ((page_w as u64 * FRAC as u64) / w.max(1) as u64) as u32;
let fy_step = ((page_h as u64 * FRAC as u64) / h.max(1) as u64) as u32;
if fx_step > FRAC || fy_step > FRAC {
composite_loop_area_avg(ctx, buf, w, h, page_w, page_h, fx_step, fy_step);
} else {
composite_loop_bilinear(ctx, buf, w, h, page_w, page_h, fx_step, fy_step);
}
Ok(())
}
pub fn render_into(
page: &DjVuPage,
opts: &RenderOptions,
buf: &mut [u8],
) -> Result<(), RenderError> {
let w = opts.width;
let h = opts.height;
if w == 0 || h == 0 {
return Err(RenderError::InvalidDimensions {
width: w,
height: h,
});
}
let need = (w as usize)
.checked_mul(h as usize)
.and_then(|n| n.checked_mul(4))
.unwrap_or(usize::MAX);
if buf.len() < need {
return Err(RenderError::BufTooSmall {
need,
got: buf.len(),
});
}
let gamma_lut = build_gamma_lut(page.gamma());
let bg = decode_background_chunks(page, usize::MAX)?;
let fg_palette = decode_fg_palette_full(page)?;
let (mask, blit_map) = if fg_palette.is_some() {
match decode_mask_indexed(page)? {
Some((bm, bm_map)) => (Some(bm), Some(bm_map)),
None => (None, None),
}
} else {
(decode_mask(page)?, None)
};
let mask = if opts.bold > 0 {
mask.map(|m| {
let mut dilated = m;
for _ in 0..opts.bold {
dilated = dilated.dilate();
}
dilated
})
} else {
mask
};
let fg44 = decode_fg44(page)?;
let ctx = CompositeContext {
opts,
page_w: page.width() as u32,
page_h: page.height() as u32,
bg: bg.as_ref(),
mask: mask.as_ref(),
fg_palette: fg_palette.as_ref(),
blit_map: blit_map.as_deref(),
fg44: fg44.as_ref(),
gamma_lut: &gamma_lut,
};
composite_into(&ctx, buf)?;
Ok(())
}
pub fn render_pixmap(page: &DjVuPage, opts: &RenderOptions) -> Result<Pixmap, RenderError> {
let w = opts.width;
let h = opts.height;
if w == 0 || h == 0 {
return Err(RenderError::InvalidDimensions {
width: w,
height: h,
});
}
let gamma_lut = build_gamma_lut(page.gamma());
let bg;
let fg_palette;
let mask;
let blit_map;
let fg44;
if opts.permissive {
bg = decode_background_chunks_permissive(page, usize::MAX);
fg_palette = decode_fg_palette_full(page).ok().flatten();
let indexed = if fg_palette.is_some() {
decode_mask_indexed(page).ok().flatten()
} else {
None
};
if let Some((bm, bm_map)) = indexed {
mask = Some(bm);
blit_map = Some(bm_map);
} else {
mask = decode_mask(page).ok().flatten();
blit_map = None;
}
fg44 = decode_fg44(page).ok().flatten();
} else {
bg = decode_background_chunks(page, usize::MAX)?;
fg_palette = decode_fg_palette_full(page)?;
let indexed_result = if fg_palette.is_some() {
decode_mask_indexed(page)?
} else {
None
};
if let Some((bm, bm_map)) = indexed_result {
mask = Some(bm);
blit_map = Some(bm_map);
} else {
mask = if fg_palette.is_none() {
decode_mask(page)?
} else {
None
};
blit_map = None;
}
fg44 = decode_fg44(page)?;
}
let mask = if opts.bold > 0 {
mask.map(|m| {
let mut dilated = m;
for _ in 0..opts.bold {
dilated = dilated.dilate();
}
dilated
})
} else {
mask
};
let mut pm = Pixmap::white(w, h);
{
let ctx = CompositeContext {
opts,
page_w: page.width() as u32,
page_h: page.height() as u32,
bg: bg.as_ref(),
mask: mask.as_ref(),
fg_palette: fg_palette.as_ref(),
blit_map: blit_map.as_deref(),
fg44: fg44.as_ref(),
gamma_lut: &gamma_lut,
};
composite_into(&ctx, &mut pm.data)?;
}
if opts.aa {
pm = aa_downscale(&pm);
}
if opts.resampling == Resampling::Lanczos3 {
let need_scale = page.width() as u32 != w || page.height() as u32 != h;
if need_scale {
let native_opts = RenderOptions {
width: page.width() as u32,
height: page.height() as u32,
scale: 1.0,
bold: opts.bold,
aa: false,
rotation: UserRotation::None, permissive: opts.permissive,
resampling: Resampling::Bilinear, };
if let Ok(native_pm) = render_pixmap(page, &native_opts) {
pm = scale_lanczos3(&native_pm, w, h);
}
}
}
Ok(rotate_pixmap(
pm,
combine_rotations(page.rotation(), opts.rotation),
))
}
pub fn render_gray8(page: &DjVuPage, opts: &RenderOptions) -> Result<GrayPixmap, RenderError> {
Ok(render_pixmap(page, opts)?.to_gray8())
}
#[cfg(feature = "parallel")]
pub fn render_pages_parallel(
doc: &crate::djvu_document::DjVuDocument,
dpi: u32,
) -> Vec<Result<Pixmap, RenderError>> {
use rayon::prelude::*;
let count = doc.page_count();
(0..count)
.into_par_iter()
.map(|i| {
let page = doc.page(i)?;
let native_dpi = page.dpi() as f32;
let scale = dpi as f32 / native_dpi;
let w = ((page.width() as f32 * scale).round() as u32).max(1);
let h = ((page.height() as f32 * scale).round() as u32).max(1);
let opts = RenderOptions {
width: w,
height: h,
scale,
bold: 0,
aa: false,
rotation: UserRotation::None,
permissive: false,
resampling: Resampling::Bilinear,
};
render_pixmap(page, &opts)
})
.collect()
}
pub fn render_coarse(page: &DjVuPage, opts: &RenderOptions) -> Result<Option<Pixmap>, RenderError> {
let w = opts.width;
let h = opts.height;
if w == 0 || h == 0 {
return Err(RenderError::InvalidDimensions {
width: w,
height: h,
});
}
let bg = decode_background_chunks(page, 1)?;
let bg = match bg {
Some(b) => b,
None => return Ok(None),
};
let gamma_lut = build_gamma_lut(page.gamma());
let mut pm = Pixmap::white(w, h);
{
let ctx = CompositeContext {
opts,
page_w: page.width() as u32,
page_h: page.height() as u32,
bg: Some(&bg),
mask: None,
fg_palette: None,
blit_map: None,
fg44: None,
gamma_lut: &gamma_lut,
};
composite_into(&ctx, &mut pm.data)?;
}
Ok(Some(rotate_pixmap(
pm,
combine_rotations(page.rotation(), opts.rotation),
)))
}
pub fn render_progressive(
page: &DjVuPage,
opts: &RenderOptions,
chunk_n: usize,
) -> Result<Pixmap, RenderError> {
let w = opts.width;
let h = opts.height;
if w == 0 || h == 0 {
return Err(RenderError::InvalidDimensions {
width: w,
height: h,
});
}
let n_bg44 = page.bg44_chunks().len();
let max_chunk = n_bg44.saturating_sub(1);
if n_bg44 > 0 && chunk_n > max_chunk {
return Err(RenderError::ChunkOutOfRange {
chunk_n,
max: max_chunk,
});
}
let gamma_lut = build_gamma_lut(page.gamma());
let bg = decode_background_chunks(page, chunk_n + 1)?;
let fg_palette = decode_fg_palette_full(page)?;
let (mask, blit_map) = if fg_palette.is_some() {
match decode_mask_indexed(page)? {
Some((bm, bm_map)) => (Some(bm), Some(bm_map)),
None => (None, None),
}
} else {
(decode_mask(page)?, None)
};
let mask = if opts.bold > 0 {
mask.map(|m| {
let mut dilated = m;
for _ in 0..opts.bold {
dilated = dilated.dilate();
}
dilated
})
} else {
mask
};
let fg44 = decode_fg44(page)?;
let mut pm = Pixmap::white(w, h);
{
let ctx = CompositeContext {
opts,
page_w: page.width() as u32,
page_h: page.height() as u32,
bg: bg.as_ref(),
mask: mask.as_ref(),
fg_palette: fg_palette.as_ref(),
blit_map: blit_map.as_deref(),
fg44: fg44.as_ref(),
gamma_lut: &gamma_lut,
};
composite_into(&ctx, &mut pm.data)?;
}
Ok(rotate_pixmap(
pm,
combine_rotations(page.rotation(), opts.rotation),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::djvu_document::DjVuDocument;
fn assets_path() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("references/djvujs/library/assets")
}
fn load_page(filename: &str) -> DjVuPage {
let data = std::fs::read(assets_path().join(filename))
.unwrap_or_else(|_| panic!("{filename} must exist"));
let doc = DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"));
let _ = doc.page(0).expect("page 0 must exist");
let data2 = std::fs::read(assets_path().join(filename)).unwrap();
let doc2 = DjVuDocument::parse(&data2).unwrap();
drop(doc2);
panic!("use load_doc_page instead")
}
fn load_doc(filename: &str) -> DjVuDocument {
let data = std::fs::read(assets_path().join(filename))
.unwrap_or_else(|_| panic!("{filename} must exist"));
DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"))
}
#[test]
fn render_options_default() {
let opts = RenderOptions::default();
assert_eq!(opts.width, 0);
assert_eq!(opts.height, 0);
assert_eq!(opts.bold, 0);
assert!(!opts.aa);
assert!((opts.scale - 1.0).abs() < 1e-6);
assert_eq!(opts.resampling, Resampling::Bilinear);
}
#[test]
fn render_options_construction() {
let opts = RenderOptions {
width: 400,
height: 300,
scale: 0.5,
bold: 1,
aa: true,
rotation: UserRotation::Cw90,
permissive: false,
resampling: Resampling::Bilinear,
};
assert_eq!(opts.width, 400);
assert_eq!(opts.height, 300);
assert_eq!(opts.bold, 1);
assert!(opts.aa);
assert!((opts.scale - 0.5).abs() < 1e-6);
assert_eq!(opts.rotation, UserRotation::Cw90);
}
#[test]
fn fit_to_width_preserves_aspect() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let pw = page.width() as u32;
let ph = page.height() as u32;
let opts = RenderOptions::fit_to_width(page, 800);
assert_eq!(opts.width, 800);
let expected_h = ((ph as f64 * 800.0) / pw as f64).round() as u32;
assert_eq!(opts.height, expected_h);
assert!((opts.scale - 800.0 / pw as f32).abs() < 0.01);
}
#[test]
fn fit_to_height_preserves_aspect() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let pw = page.width() as u32;
let ph = page.height() as u32;
let opts = RenderOptions::fit_to_height(page, 600);
assert_eq!(opts.height, 600);
let expected_w = ((pw as f64 * 600.0) / ph as f64).round() as u32;
assert_eq!(opts.width, expected_w);
assert!((opts.scale - 600.0 / ph as f32).abs() < 0.01);
}
#[test]
fn fit_to_box_constrains_both() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let opts = RenderOptions::fit_to_box(page, 10000, 100);
assert!(opts.width <= 10000);
assert!(opts.height <= 100);
assert!(opts.width > 0 && opts.height > 0);
let opts = RenderOptions::fit_to_box(page, 100, 10000);
assert!(opts.width <= 100);
assert!(opts.height <= 10000);
assert!(opts.width > 0 && opts.height > 0);
}
#[test]
fn fit_to_box_square() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let opts = RenderOptions::fit_to_box(page, 500, 500);
assert!(opts.width <= 500);
assert!(opts.height <= 500);
assert!(opts.width >= 490 || opts.height >= 490);
}
#[test]
fn fit_to_width_rotation_aware() {
let doc = load_doc("boy_jb2_rotate90.djvu");
let page = doc.page(0).unwrap();
let pw = page.width() as u32;
let ph = page.height() as u32;
let (dw, dh) = (ph, pw);
let opts = RenderOptions::fit_to_width(page, 400);
assert_eq!(opts.width, 400);
let expected_h = ((dh as f64 * 400.0) / dw as f64).round() as u32;
assert_eq!(opts.height, expected_h);
}
#[test]
fn render_into_invalid_dimensions() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let opts = RenderOptions {
width: 0,
height: 100,
..Default::default()
};
let mut buf = vec![0u8; 400];
let err = render_into(page, &opts, &mut buf).unwrap_err();
assert!(
matches!(err, RenderError::InvalidDimensions { .. }),
"expected InvalidDimensions, got {err:?}"
);
}
#[test]
fn render_into_buf_too_small() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let opts = RenderOptions {
width: 10,
height: 10,
..Default::default()
};
let mut buf = vec![0u8; 10]; let err = render_into(page, &opts, &mut buf).unwrap_err();
assert!(
matches!(err, RenderError::BufTooSmall { need: 400, got: 10 }),
"expected BufTooSmall, got {err:?}"
);
}
#[test]
fn render_into_fills_buffer_no_alloc() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let w = 50u32;
let h = 40u32;
let opts = RenderOptions {
width: w,
height: h,
..Default::default()
};
let mut buf = vec![0u8; (w * h * 4) as usize];
render_into(page, &opts, &mut buf).expect("render_into should succeed");
assert!(
buf.iter().any(|&b| b != 0),
"rendered buffer should contain non-zero pixels"
);
}
#[test]
fn render_into_reuse_buffer() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let w = 30u32;
let h = 20u32;
let opts = RenderOptions {
width: w,
height: h,
..Default::default()
};
let mut buf = vec![0u8; (w * h * 4) as usize];
render_into(page, &opts, &mut buf).expect("first render_into should succeed");
let first = buf.clone();
render_into(page, &opts, &mut buf).expect("second render_into should succeed");
assert_eq!(
first, buf,
"repeated render_into should produce identical output"
);
}
#[test]
fn gamma_correction_changes_pixels() {
let lut_gamma = build_gamma_lut(2.2);
let lut_identity = build_gamma_lut(1.0);
let mid = 128u8;
let corrected = lut_gamma[mid as usize];
let identity = lut_identity[mid as usize];
assert_eq!(identity, mid, "identity LUT must be identity");
assert!(
corrected > mid,
"gamma-corrected midtone ({corrected}) should be > identity ({mid})"
);
}
#[test]
fn gamma_lut_identity() {
let lut = build_gamma_lut(1.0);
for (i, &val) in lut.iter().enumerate() {
assert_eq!(val, i as u8, "identity LUT at {i}: expected {i}, got {val}");
}
}
#[test]
fn gamma_lut_zero_is_identity() {
let lut = build_gamma_lut(0.0);
for (i, &val) in lut.iter().enumerate() {
assert_eq!(val, i as u8, "zero gamma should produce identity LUT");
}
}
#[test]
fn render_coarse_returns_pixmap() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let opts = RenderOptions {
width: 60,
height: 80,
..Default::default()
};
let result = render_coarse(page, &opts).expect("render_coarse should succeed");
if let Some(pm) = result {
assert_eq!(pm.width, 60);
assert_eq!(pm.height, 80);
assert_eq!(pm.data.len(), 60 * 80 * 4);
}
}
#[test]
fn render_progressive_each_chunk() {
let doc = load_doc("boy.djvu");
let page = doc.page(0).unwrap();
let opts = RenderOptions {
width: 80,
height: 100,
..Default::default()
};
let n_bg44 = page.bg44_chunks().len();
for chunk_n in 0..n_bg44 {
let pm = render_progressive(page, &opts, chunk_n)
.unwrap_or_else(|e| panic!("render_progressive chunk {chunk_n} failed: {e}"));
assert_eq!(pm.width, 80);
assert_eq!(pm.height, 100);
assert_eq!(pm.data.len(), 80 * 100 * 4);
assert!(
pm.data.iter().any(|&b| b != 0),
"chunk {chunk_n}: rendered frame should not be all-zero"
);
}
}
#[test]
fn render_progressive_chunk_out_of_range() {
let doc = load_doc("boy.djvu");
let page = doc.page(0).unwrap();
let opts = RenderOptions {
width: 40,
height: 50,
..Default::default()
};
let n_bg44 = page.bg44_chunks().len();
if n_bg44 == 0 {
return;
}
let err = render_progressive(page, &opts, n_bg44 + 10).unwrap_err();
assert!(
matches!(err, RenderError::ChunkOutOfRange { .. }),
"expected ChunkOutOfRange, got {err:?}"
);
}
#[test]
fn render_pixmap_gamma_differs_from_identity() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let w = 40u32;
let h = 53u32;
let opts = RenderOptions {
width: w,
height: h,
..Default::default()
};
let pm_gamma = render_pixmap(page, &opts).expect("render with gamma should succeed");
let lut_identity = build_gamma_lut(1.0);
let pm_identity = render_pixmap(page, &opts).expect("render for identity should succeed");
for i in 0..pm_identity.data.len().saturating_sub(3) {
if i % 4 != 3 {
let _ = lut_identity[pm_identity.data[i] as usize];
}
}
assert_eq!(pm_gamma.width, w);
assert_eq!(pm_gamma.height, h);
assert!(
pm_gamma.data.iter().any(|&b| b != 255),
"should have non-white pixels"
);
}
#[test]
fn render_bilevel_page_has_black_pixels() {
let doc = load_doc("boy_jb2.djvu");
let page = doc.page(0).unwrap();
let opts = RenderOptions {
width: 60,
height: 80,
..Default::default()
};
let pm = render_pixmap(page, &opts).expect("render bilevel should succeed");
assert_eq!(pm.width, 60);
assert_eq!(pm.height, 80);
assert!(
pm.data
.chunks_exact(4)
.any(|px| px[0] == 0 && px[1] == 0 && px[2] == 0),
"bilevel page should contain black pixels"
);
}
#[test]
fn render_with_aa() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let opts = RenderOptions {
width: 40,
height: 54,
aa: true,
..Default::default()
};
let pm = render_pixmap(page, &opts).expect("render with AA should succeed");
assert_eq!(pm.width, 20);
assert_eq!(pm.height, 27);
}
#[allow(dead_code)]
fn _unused_load_page(_: &str) -> ! {
let _ = load_page; panic!("use load_doc instead")
}
#[test]
fn rotate_pixmap_none_is_identity() {
let mut pm = Pixmap::white(3, 2);
pm.set_rgb(0, 0, 255, 0, 0);
let rotated = rotate_pixmap(pm.clone(), crate::info::Rotation::None);
assert_eq!(rotated.width, 3);
assert_eq!(rotated.height, 2);
assert_eq!(rotated.get_rgb(0, 0), (255, 0, 0));
}
#[test]
fn rotate_pixmap_cw90_swaps_dims() {
let mut pm = Pixmap::white(4, 2);
pm.set_rgb(0, 0, 255, 0, 0); let rotated = rotate_pixmap(pm, crate::info::Rotation::Cw90);
assert_eq!(rotated.width, 2);
assert_eq!(rotated.height, 4);
assert_eq!(rotated.get_rgb(1, 0), (255, 0, 0));
}
#[test]
fn rotate_pixmap_180_preserves_dims() {
let mut pm = Pixmap::white(3, 2);
pm.set_rgb(0, 0, 255, 0, 0); let rotated = rotate_pixmap(pm, crate::info::Rotation::Rot180);
assert_eq!(rotated.width, 3);
assert_eq!(rotated.height, 2);
assert_eq!(rotated.get_rgb(2, 1), (255, 0, 0));
}
#[test]
fn rotate_pixmap_ccw90_swaps_dims() {
let mut pm = Pixmap::white(4, 2);
pm.set_rgb(0, 0, 255, 0, 0); let rotated = rotate_pixmap(pm, crate::info::Rotation::Ccw90);
assert_eq!(rotated.width, 2);
assert_eq!(rotated.height, 4);
assert_eq!(rotated.get_rgb(0, 3), (255, 0, 0));
}
#[test]
fn render_pixmap_rotation_90_swaps_dimensions() {
let doc = load_doc("boy_jb2_rotate90.djvu");
let page = doc.page(0).expect("page 0");
let orig_w = page.width();
let orig_h = page.height();
let opts = RenderOptions {
width: orig_w as u32,
height: orig_h as u32,
..Default::default()
};
let pm = render_pixmap(page, &opts).expect("render should succeed");
assert_eq!(
pm.width, orig_h as u32,
"rotated width should be original height"
);
assert_eq!(
pm.height, orig_w as u32,
"rotated height should be original width"
);
}
#[test]
fn render_pixmap_rotation_180_preserves_dimensions() {
let doc = load_doc("boy_jb2_rotate180.djvu");
let page = doc.page(0).expect("page 0");
let orig_w = page.width();
let orig_h = page.height();
let opts = RenderOptions {
width: orig_w as u32,
height: orig_h as u32,
..Default::default()
};
let pm = render_pixmap(page, &opts).expect("render should succeed");
assert_eq!(pm.width, orig_w as u32);
assert_eq!(pm.height, orig_h as u32);
}
#[test]
fn render_pixmap_rotation_270_swaps_dimensions() {
let doc = load_doc("boy_jb2_rotate270.djvu");
let page = doc.page(0).expect("page 0");
let orig_w = page.width();
let orig_h = page.height();
let opts = RenderOptions {
width: orig_w as u32,
height: orig_h as u32,
..Default::default()
};
let pm = render_pixmap(page, &opts).expect("render should succeed");
assert_eq!(
pm.width, orig_h as u32,
"rotated width should be original height"
);
assert_eq!(
pm.height, orig_w as u32,
"rotated height should be original width"
);
}
#[test]
fn combine_rotations_identity() {
use crate::info::Rotation;
assert_eq!(
combine_rotations(Rotation::None, UserRotation::None),
Rotation::None
);
}
#[test]
fn combine_rotations_info_only() {
use crate::info::Rotation;
assert_eq!(
combine_rotations(Rotation::Cw90, UserRotation::None),
Rotation::Cw90
);
}
#[test]
fn combine_rotations_user_only() {
use crate::info::Rotation;
assert_eq!(
combine_rotations(Rotation::None, UserRotation::Ccw90),
Rotation::Ccw90
);
}
#[test]
fn combine_rotations_sum() {
use crate::info::Rotation;
assert_eq!(
combine_rotations(Rotation::Cw90, UserRotation::Cw90),
Rotation::Rot180
);
assert_eq!(
combine_rotations(Rotation::Cw90, UserRotation::Ccw90),
Rotation::None
);
assert_eq!(
combine_rotations(Rotation::Rot180, UserRotation::Rot180),
Rotation::None
);
}
#[test]
fn user_rotation_cw90_swaps_dimensions() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let pw = page.width() as u32;
let ph = page.height() as u32;
let opts = RenderOptions {
width: pw,
height: ph,
rotation: UserRotation::Cw90,
..Default::default()
};
let pm = render_pixmap(page, &opts).expect("render");
assert_eq!(pm.width, ph, "user Cw90 should swap: width becomes height");
assert_eq!(pm.height, pw, "user Cw90 should swap: height becomes width");
}
#[test]
fn user_rotation_180_preserves_dimensions() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let pw = page.width() as u32;
let ph = page.height() as u32;
let opts = RenderOptions {
width: pw,
height: ph,
rotation: UserRotation::Rot180,
..Default::default()
};
let pm = render_pixmap(page, &opts).expect("render");
assert_eq!(pm.width, pw);
assert_eq!(pm.height, ph);
}
#[test]
fn user_rotation_default_is_none() {
assert_eq!(UserRotation::default(), UserRotation::None);
let opts = RenderOptions::default();
assert_eq!(opts.rotation, UserRotation::None);
}
#[test]
fn fgbz_palette_page_renders_multiple_colors() {
let doc = load_doc("irish.djvu");
let page = doc.page(0).expect("page 0");
let w = page.width() as u32;
let h = page.height() as u32;
let opts = RenderOptions {
width: w,
height: h,
..Default::default()
};
let pm = render_pixmap(page, &opts).expect("render should succeed");
let mut fg_colors = std::collections::HashSet::new();
for y in 0..h {
for x in 0..w {
let (r, g, b) = pm.get_rgb(x, y);
if r > 240 && g > 240 && b > 240 {
continue;
}
fg_colors.insert((r, g, b));
}
}
assert!(
fg_colors.len() > 1,
"multi-color palette page should have >1 distinct foreground colors, got {}",
fg_colors.len()
);
}
#[test]
fn lookup_palette_color_uses_blit_map() {
let pal = FgbzPalette {
colors: vec![
PaletteColor { r: 255, g: 0, b: 0 }, PaletteColor { r: 0, g: 0, b: 255 }, ],
indices: vec![1, 0], };
let bm = crate::bitmap::Bitmap::new(2, 1);
let blit_map = vec![0i32, 1i32];
let c0 = lookup_palette_color(&pal, Some(&blit_map), Some(&bm), 0, 0);
assert_eq!(
(c0.r, c0.g, c0.b),
(0, 0, 255),
"blit 0 → indices[0]=1 → blue"
);
let c1 = lookup_palette_color(&pal, Some(&blit_map), Some(&bm), 1, 0);
assert_eq!(
(c1.r, c1.g, c1.b),
(255, 0, 0),
"blit 1 → indices[1]=0 → red"
);
}
#[test]
fn lookup_palette_color_fallback_without_blit_map() {
let pal = FgbzPalette {
colors: vec![PaletteColor { r: 0, g: 128, b: 0 }],
indices: vec![],
};
let c = lookup_palette_color(&pal, None, None, 0, 0);
assert_eq!(
(c.r, c.g, c.b),
(0, 128, 0),
"should fall back to first color"
);
}
fn load_bgjp_doc() -> DjVuDocument {
load_doc("bgjp_test.djvu")
}
#[test]
fn bgjp_fixture_loads() {
let doc = load_bgjp_doc();
let page = doc.page(0).unwrap();
assert_eq!(page.width(), 4);
assert_eq!(page.height(), 4);
}
#[test]
fn bgjp_chunk_present() {
let doc = load_bgjp_doc();
let page = doc.page(0).unwrap();
assert!(
page.find_chunk(b"BGjp").is_some(),
"fixture must have a BGjp chunk"
);
assert!(
page.bg44_chunks().is_empty(),
"fixture must NOT have BG44 chunks"
);
}
#[test]
fn decode_bgjp_returns_pixmap() {
let doc = load_bgjp_doc();
let page = doc.page(0).unwrap();
let pm = decode_bgjp(page).expect("decode_bgjp must not error");
assert!(pm.is_some(), "decode_bgjp must return Some(Pixmap)");
let pm = pm.unwrap();
assert_eq!(pm.width, 4);
assert_eq!(pm.height, 4);
assert_eq!(pm.data.len(), 4 * 4 * 4); }
#[test]
fn decode_bgjp_returns_none_without_chunk() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let pm = decode_bgjp(page).expect("should not error");
assert!(pm.is_none());
}
#[test]
fn decode_jpeg_to_pixmap_alpha_is_255() {
let doc = load_bgjp_doc();
let page = doc.page(0).unwrap();
let data = page.find_chunk(b"BGjp").unwrap();
let pm = decode_jpeg_to_pixmap(data).expect("decode must succeed");
for chunk in pm.data.chunks_exact(4) {
assert_eq!(chunk[3], 255, "alpha must be 255 for every pixel");
}
}
#[test]
fn render_pixmap_uses_bgjp_background() {
let doc = load_bgjp_doc();
let page = doc.page(0).unwrap();
let opts = RenderOptions {
width: 4,
height: 4,
scale: 1.0,
bold: 0,
aa: false,
rotation: UserRotation::None,
permissive: false,
resampling: Resampling::Bilinear,
};
let pm = render_pixmap(page, &opts).expect("render must succeed");
assert_eq!(pm.width, 4);
assert_eq!(pm.height, 4);
}
#[test]
fn render_coarse_uses_bgjp_background() {
let doc = load_bgjp_doc();
let page = doc.page(0).unwrap();
let opts = RenderOptions {
width: 4,
height: 4,
scale: 1.0,
bold: 0,
aa: false,
rotation: UserRotation::None,
permissive: false,
resampling: Resampling::Bilinear,
};
let pm = render_coarse(page, &opts).expect("render_coarse must succeed");
assert!(pm.is_some(), "must return Some when BGjp present");
let pm = pm.unwrap();
assert_eq!(pm.width, 4);
assert_eq!(pm.height, 4);
}
#[test]
fn lanczos3_kernel_unity_at_zero() {
assert!((lanczos3_kernel(0.0) - 1.0).abs() < 1e-5);
}
#[test]
fn lanczos3_kernel_zero_outside_support() {
assert_eq!(lanczos3_kernel(3.0), 0.0);
assert_eq!(lanczos3_kernel(-3.5), 0.0);
assert_eq!(lanczos3_kernel(10.0), 0.0);
}
#[test]
fn scale_lanczos3_correct_dimensions() {
let src = Pixmap::white(100, 80);
let dst = scale_lanczos3(&src, 50, 40);
assert_eq!(dst.width, 50);
assert_eq!(dst.height, 40);
}
#[test]
fn scale_lanczos3_noop_when_same_size() {
let src = Pixmap::new(4, 4, 200, 100, 50, 255);
let dst = scale_lanczos3(&src, 4, 4);
assert_eq!(dst.width, 4);
assert_eq!(dst.height, 4);
assert_eq!(dst.data, src.data);
}
#[test]
fn scale_lanczos3_preserves_solid_color() {
let src = Pixmap::new(20, 20, 200, 0, 0, 255);
let dst = scale_lanczos3(&src, 10, 10);
assert_eq!(dst.width, 10);
assert_eq!(dst.height, 10);
for chunk in dst.data.chunks_exact(4) {
let (r, g, b) = (chunk[0], chunk[1], chunk[2]);
assert!(
(r as i32 - 200).abs() <= 5 && g <= 5 && b <= 5,
"expected near-red (200,0,0), got ({r},{g},{b})"
);
}
}
#[test]
fn render_pixmap_lanczos3_correct_dimensions() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let pw = page.width() as u32;
let ph = page.height() as u32;
let tw = pw / 2;
let th = ph / 2;
let opts = RenderOptions {
width: tw,
height: th,
scale: 0.5,
resampling: Resampling::Lanczos3,
..Default::default()
};
let pm = render_pixmap(page, &opts).expect("Lanczos3 render must succeed");
assert_eq!(pm.width, tw);
assert_eq!(pm.height, th);
}
#[test]
fn lanczos3_differs_from_bilinear_at_half_scale() {
let doc = load_doc("chicken.djvu");
let page = doc.page(0).unwrap();
let pw = page.width() as u32;
let ph = page.height() as u32;
let tw = pw / 2;
let th = ph / 2;
let bilinear = render_pixmap(
page,
&RenderOptions {
width: tw,
height: th,
scale: 0.5,
resampling: Resampling::Bilinear,
..Default::default()
},
)
.unwrap();
let lanczos = render_pixmap(
page,
&RenderOptions {
width: tw,
height: th,
scale: 0.5,
resampling: Resampling::Lanczos3,
..Default::default()
},
)
.unwrap();
assert_eq!(bilinear.width, lanczos.width);
assert_eq!(bilinear.height, lanczos.height);
let differ = bilinear
.data
.iter()
.zip(lanczos.data.iter())
.any(|(a, b)| a != b);
assert!(
differ,
"Lanczos3 and bilinear must produce different pixel values"
);
}
#[test]
fn resampling_default_is_bilinear() {
let opts = RenderOptions::default();
assert_eq!(opts.resampling, Resampling::Bilinear);
}
}