#[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::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),
}
#[derive(Debug, Clone, PartialEq)]
pub struct RenderOptions {
pub width: u32,
pub height: u32,
pub scale: f32,
pub bold: u8,
pub aa: bool,
}
impl Default for RenderOptions {
fn default() -> Self {
RenderOptions {
width: 0,
height: 0,
scale: 1.0,
bold: 0,
aa: false,
}
}
}
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),
)
}
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
}
#[derive(Debug, Clone, Copy, Default)]
struct PaletteColor {
r: u8,
g: u8,
b: u8,
}
fn parse_fgbz_palette(data: &[u8]) -> Result<Vec<PaletteColor>, RenderError> {
if data.len() < 3 {
return Ok(vec![]);
}
let _version = data[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(vec![]);
}
let palette_data = data.get(3..).unwrap_or(&[]);
let raw_colors = if _version == 1 {
crate::bzz_new::bzz_decode(palette_data)?
} else {
palette_data.to_vec()
};
let expected = n_colors * 3;
let available = raw_colors.len().min(expected);
let mut colors = Vec::with_capacity(n_colors);
for i in 0..n_colors {
let base = i * 3;
if base + 2 < available {
colors.push(PaletteColor {
r: raw_colors[base + 2],
g: raw_colors[base + 1],
b: raw_colors[base],
});
} else {
colors.push(PaletteColor { r: 0, g: 0, b: 0 });
}
}
Ok(colors)
}
fn decode_background_chunks(
page: &DjVuPage,
max_chunks: usize,
) -> Result<Option<Pixmap>, RenderError> {
let bg44_chunks = page.bg44_chunks();
if bg44_chunks.is_empty() {
return Ok(None);
}
let mut img = Iw44Image::new();
for chunk_data in bg44_chunks.iter().take(max_chunks) {
img.decode_chunk(chunk_data)?;
}
let pm = img.to_rgb()?;
Ok(Some(pm))
}
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_fg_palette(page: &DjVuPage) -> Result<Option<Vec<PaletteColor>>, RenderError> {
let fgbz = match page.find_chunk(b"FGbz") {
Some(data) => data,
None => return Ok(None),
};
let colors = parse_fgbz_palette(fgbz)?;
if colors.is_empty() {
return Ok(None);
}
Ok(Some(colors))
}
fn decode_fg44(page: &DjVuPage) -> Result<Option<Pixmap>, RenderError> {
let fg44_chunks = page.fg44_chunks();
if fg44_chunks.is_empty() {
return Ok(None);
}
let mut img = Iw44Image::new();
for chunk_data in &fg44_chunks {
img.decode_chunk(chunk_data)?;
}
let pm = img.to_rgb()?;
Ok(Some(pm))
}
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 [PaletteColor]>,
fg44: Option<&'a Pixmap>,
gamma_lut: &'a [u8; 256],
}
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;
for oy in 0..h {
for ox in 0..w {
let fx = ox * fx_step;
let fy = oy * fy_step;
let px = (fx >> FRACBITS).min(page_w.saturating_sub(1));
let py = (fy >> FRACBITS).min(page_h.saturating_sub(1));
let (mut r, mut g, mut b) = (255u8, 255u8, 255u8);
if let Some(bg) = ctx.bg {
let (br, bg_c, bb) = sample_bilinear(bg, fx, fy);
r = br;
g = bg_c;
b = bb;
}
let is_fg = ctx
.mask
.is_some_and(|m| px < m.width && py < m.height && m.get(px, py));
if is_fg {
if let Some(palette) = ctx.fg_palette {
let color = palette.first().copied().unwrap_or_default();
r = color.r;
g = color.g;
b = color.b;
} else if let Some(fg) = ctx.fg44 {
let (fr, fg_c, fb) = sample_bilinear(fg, fx, fy);
r = fr;
g = fg_c;
b = fb;
} else {
r = 0;
g = 0;
b = 0;
}
}
r = ctx.gamma_lut[r as usize];
g = ctx.gamma_lut[g as usize];
b = ctx.gamma_lut[b as usize];
let base = (oy as usize * w as usize + 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;
}
}
}
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 mask = decode_mask(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 fg_palette = decode_fg_palette(page)?;
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_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 = decode_background_chunks(page, usize::MAX)?;
let mask = decode_mask(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 fg_palette = decode_fg_palette(page)?;
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_deref(),
fg44: fg44.as_ref(),
gamma_lut: &gamma_lut,
};
composite_into(&ctx, &mut pm.data)?;
}
if opts.aa {
pm = aa_downscale(&pm);
}
Ok(pm)
}
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,
fg44: None,
gamma_lut: &gamma_lut,
};
composite_into(&ctx, &mut pm.data)?;
}
Ok(Some(pm))
}
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 mask = decode_mask(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 fg_palette = decode_fg_palette(page)?;
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_deref(),
fg44: fg44.as_ref(),
gamma_lut: &gamma_lut,
};
composite_into(&ctx, &mut pm.data)?;
}
Ok(pm)
}
#[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);
}
#[test]
fn render_options_construction() {
let opts = RenderOptions {
width: 400,
height: 300,
scale: 0.5,
bold: 1,
aa: true,
};
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);
}
#[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,
scale: 1.0,
bold: 0,
aa: false,
};
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")
}
}