use crate::bitmap::Bitmap;
use crate::bzz_encode::bzz_encode;
use crate::chunk_encode::{ChunkEncoder, EncodedChunk, FgbzChunk, encode_info};
use crate::fgbz_encode::FgbzColor;
use crate::iff::{Chunk, DjvuFile, emit};
use crate::iw44_encode::{Iw44EncodeOptions, encode_iw44_color};
use crate::jb2_encode::{self, Jb2EncodeOptions};
use crate::ocr::{OcrBackend, OcrError, OcrOptions};
use crate::pixmap::Pixmap;
use crate::segment::{SegmentOptions, segment_page};
use crate::text::TextLayer;
use crate::text_encode::encode_text_layer;
#[derive(Debug, thiserror::Error)]
pub enum EncodeError {
#[error("page encoder: {0}")]
Unsupported(&'static str),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FgbzPaletteOptions {
#[default]
Exact,
MedianCut {
max_colors: u16,
},
}
#[derive(Debug, Clone, Copy)]
struct WeightedColor {
r: u8,
g: u8,
b: u8,
weight: u64,
}
fn median_cut(colors: &[WeightedColor], k: usize) -> Vec<FgbzColor> {
if colors.is_empty() {
return Vec::new();
}
let k = k.max(1);
let mut boxes: Vec<Vec<usize>> = vec![(0..colors.len()).collect()];
while boxes.len() < k {
let mut best: Option<(usize, usize, u16)> = None; for (bi, b) in boxes.iter().enumerate() {
if b.len() < 2 {
continue;
}
let (mut rmin, mut rmax) = (255u8, 0u8);
let (mut gmin, mut gmax) = (255u8, 0u8);
let (mut bmin, mut bmax) = (255u8, 0u8);
for &i in b {
let c = colors[i];
rmin = rmin.min(c.r);
rmax = rmax.max(c.r);
gmin = gmin.min(c.g);
gmax = gmax.max(c.g);
bmin = bmin.min(c.b);
bmax = bmax.max(c.b);
}
let ranges = [
(0usize, rmax as u16 - rmin as u16),
(1usize, gmax as u16 - gmin as u16),
(2usize, bmax as u16 - bmin as u16),
];
let (channel, range) = ranges.into_iter().max_by_key(|&(_, r)| r).unwrap_or((0, 0));
if range == 0 {
continue; }
match best {
Some((_, _, best_range)) if best_range >= range => {}
_ => best = Some((bi, channel, range)),
}
}
let Some((bi, channel, _)) = best else {
break; };
let mut b = boxes.remove(bi);
b.sort_by_key(|&i| {
let c = colors[i];
(
match channel {
0 => c.r,
1 => c.g,
_ => c.b,
},
i,
)
});
let mid = b.len() / 2;
let right = b.split_off(mid);
boxes.push(b);
boxes.push(right);
}
boxes
.into_iter()
.filter(|b| !b.is_empty())
.map(|b| {
let (mut sr, mut sg, mut sb, mut sw) = (0u64, 0u64, 0u64, 0u64);
for i in b {
let c = colors[i];
let w = c.weight.max(1);
sr += u64::from(c.r) * w;
sg += u64::from(c.g) * w;
sb += u64::from(c.b) * w;
sw += w;
}
let sw = sw.max(1);
FgbzColor {
r: (sr / sw) as u8,
g: (sg / sw) as u8,
b: (sb / sw) as u8,
}
})
.collect()
}
fn nearest_palette_index(palette: &[FgbzColor], c: FgbzColor) -> usize {
palette
.iter()
.enumerate()
.min_by_key(|&(_, p)| {
let dr = i32::from(p.r) - i32::from(c.r);
let dg = i32::from(p.g) - i32::from(c.g);
let db = i32::from(p.b) - i32::from(c.b);
dr * dr + dg * dg + db * db
})
.map(|(i, _)| i)
.unwrap_or(0)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EncodeQuality {
#[default]
Lossless,
Quality,
Archival,
}
impl EncodeQuality {
pub fn default_segment_options(self) -> SegmentOptions {
match self {
EncodeQuality::Archival => SegmentOptions {
bg_diffuse: true,
..SegmentOptions::archival()
},
EncodeQuality::Quality => SegmentOptions {
bg_diffuse: true,
..SegmentOptions::default()
},
EncodeQuality::Lossless => SegmentOptions::default(),
}
}
}
enum Source<'a> {
Bitmap(&'a Bitmap),
Pixmap(&'a Pixmap),
}
impl Source<'_> {
fn dimensions(&self) -> (u32, u32) {
match self {
Source::Bitmap(b) => (b.width, b.height),
Source::Pixmap(p) => (p.width, p.height),
}
}
}
pub struct PageEncoder<'a> {
source: Source<'a>,
dpi: u16,
quality: EncodeQuality,
segment_options: Option<SegmentOptions>,
iw44_options: Option<Iw44EncodeOptions>,
jb2_options: Option<Jb2EncodeOptions>,
fgbz_options: FgbzPaletteOptions,
text_layer: Option<TextLayer>,
}
impl<'a> PageEncoder<'a> {
pub fn from_bitmap(bitmap: &'a Bitmap) -> Self {
Self {
source: Source::Bitmap(bitmap),
dpi: 300,
quality: EncodeQuality::Lossless,
segment_options: None,
iw44_options: None,
jb2_options: None,
fgbz_options: FgbzPaletteOptions::Exact,
text_layer: None,
}
}
pub fn from_pixmap(pixmap: &'a Pixmap) -> Self {
Self {
source: Source::Pixmap(pixmap),
dpi: 300,
quality: EncodeQuality::Quality,
segment_options: None,
iw44_options: None,
jb2_options: None,
fgbz_options: FgbzPaletteOptions::Exact,
text_layer: None,
}
}
pub fn with_dpi(mut self, dpi: u16) -> Self {
self.dpi = dpi.max(1);
self
}
pub fn with_quality(mut self, quality: EncodeQuality) -> Self {
self.quality = quality;
self
}
pub fn with_segment_options(mut self, opts: SegmentOptions) -> Self {
self.segment_options = Some(opts);
self
}
pub fn with_iw44_options(mut self, opts: Iw44EncodeOptions) -> Self {
self.iw44_options = Some(opts);
self
}
pub fn with_jb2_options(mut self, opts: Jb2EncodeOptions) -> Self {
self.jb2_options = Some(opts);
self
}
pub fn with_fgbz_options(mut self, opts: FgbzPaletteOptions) -> Self {
self.fgbz_options = opts;
self
}
pub fn with_text_layer(mut self, layer: TextLayer) -> Self {
self.text_layer = Some(layer);
self
}
pub fn with_ocr_text_layer(
mut self,
backend: &dyn OcrBackend,
options: &OcrOptions,
) -> Result<Self, OcrError> {
let owned_pixmap;
let pixmap: &Pixmap = match &self.source {
Source::Pixmap(p) => p,
Source::Bitmap(b) => {
owned_pixmap = bitmap_to_pixmap(b);
&owned_pixmap
}
};
let layer = backend.recognize(pixmap, options)?;
self.text_layer = Some(layer);
Ok(self)
}
pub fn encode(&self) -> Result<Vec<u8>, EncodeError> {
let (w, h) = self.source.dimensions();
let w = u16::try_from(w).map_err(|_| {
EncodeError::Unsupported("page width exceeds INFO chunk limit (65 535 px)")
})?;
let h = u16::try_from(h).map_err(|_| {
EncodeError::Unsupported("page height exceeds INFO chunk limit (65 535 px)")
})?;
let info = encode_info(w, h, self.dpi);
match (&self.source, self.quality) {
(Source::Bitmap(bm), EncodeQuality::Lossless) => {
let mut chunks = vec![
Chunk::Leaf {
id: *b"INFO",
data: info,
},
Chunk::Leaf {
id: *b"Sjbz",
data: jb2_encode::encode_jb2(bm),
},
];
self.push_text_layer_chunk(&mut chunks, h as u32);
Ok(encode_form_djvu(chunks))
}
(Source::Pixmap(pm), EncodeQuality::Quality | EncodeQuality::Archival) => {
let segment_options = self
.segment_options
.unwrap_or_else(|| self.quality.default_segment_options());
let seg = segment_page(pm, &segment_options);
let sjbz = jb2_encode::encode_jb2_dict_with_options(
&seg.mask,
&[],
&self.jb2_options.unwrap_or_default(),
);
let bg44_chunks =
encode_iw44_color(&seg.bg, &self.iw44_options.unwrap_or_default());
let fgbz = foreground_fgbz(pm, &seg.mask, &sjbz, None, self.fgbz_options);
let mut chunks =
Vec::with_capacity(2 + bg44_chunks.len() + usize::from(fgbz.is_some()) + 1);
chunks.push(Chunk::Leaf {
id: *b"INFO",
data: info,
});
chunks.push(Chunk::Leaf {
id: *b"Sjbz",
data: sjbz,
});
for body in bg44_chunks {
chunks.push(Chunk::Leaf {
id: *b"BG44",
data: body,
});
}
if let Some(chunk) = fgbz {
chunks.push(chunk.into_leaf());
}
self.push_text_layer_chunk(&mut chunks, h as u32);
Ok(encode_form_djvu(chunks))
}
(Source::Pixmap(_), EncodeQuality::Lossless) => Err(EncodeError::Unsupported(
"Lossless requires bilevel input — use from_bitmap or switch to Quality",
)),
(Source::Bitmap(_), EncodeQuality::Quality) => Err(EncodeError::Unsupported(
"Quality requires colour input — use from_pixmap or switch to Lossless",
)),
(Source::Bitmap(_), EncodeQuality::Archival) => Err(EncodeError::Unsupported(
"Archival requires colour input — use from_pixmap or switch to Lossless",
)),
}
}
fn push_text_layer_chunk(&self, chunks: &mut Vec<Chunk>, page_height: u32) {
if let Some(layer) = &self.text_layer {
let plain = encode_text_layer(layer, page_height);
let compressed = bzz_encode(&plain);
chunks.push(Chunk::Leaf {
id: *b"TXTz",
data: compressed,
});
}
}
}
fn bitmap_to_pixmap(bm: &Bitmap) -> Pixmap {
let mut pm = Pixmap::white(bm.width, bm.height);
for y in 0..bm.height {
for x in 0..bm.width {
if bm.get(x, y) {
pm.set_rgb(x, y, 0, 0, 0);
}
}
}
pm
}
pub fn encode_djvm_layered_shared(
pixmaps: &[Pixmap],
quality: EncodeQuality,
dpi: u16,
segment_options: Option<SegmentOptions>,
shared_dict_page_threshold: usize,
) -> Result<Vec<u8>, EncodeError> {
encode_djvm_layered_shared_impl(
pixmaps,
quality,
dpi,
segment_options,
shared_dict_page_threshold,
false,
)
}
pub fn encode_djvm_layered_shared_with_thumbnails(
pixmaps: &[Pixmap],
quality: EncodeQuality,
dpi: u16,
segment_options: Option<SegmentOptions>,
shared_dict_page_threshold: usize,
with_thumbnails: bool,
) -> Result<Vec<u8>, EncodeError> {
encode_djvm_layered_shared_impl(
pixmaps,
quality,
dpi,
segment_options,
shared_dict_page_threshold,
with_thumbnails,
)
}
fn encode_djvm_layered_shared_impl(
pixmaps: &[Pixmap],
quality: EncodeQuality,
dpi: u16,
segment_options: Option<SegmentOptions>,
shared_dict_page_threshold: usize,
with_thumbnails: bool,
) -> Result<Vec<u8>, EncodeError> {
if !matches!(quality, EncodeQuality::Quality | EncodeQuality::Archival) {
return Err(EncodeError::Unsupported(
"encode_djvm_layered_shared requires the Quality or Archival profile",
));
}
let opts = segment_options.unwrap_or_else(|| quality.default_segment_options());
#[cfg(feature = "parallel")]
let segs: Vec<_> = {
use rayon::prelude::*;
pixmaps
.par_iter()
.map(|pm| segment_page(pm, &opts))
.collect()
};
#[cfg(not(feature = "parallel"))]
let segs: Vec<_> = pixmaps.iter().map(|pm| segment_page(pm, &opts)).collect();
let masks: Vec<Bitmap> = segs.iter().map(|s| s.mask.clone()).collect();
let shared = jb2_encode::cluster_shared_symbols(&masks, shared_dict_page_threshold);
let has_shared = !shared.is_empty();
let dict_id = "dict0001.djvi";
let mut comps: Vec<(Vec<u8>, bool, String)> = Vec::new();
let shared_dict = if has_shared {
let djbz = jb2_encode::encode_jb2_djbz(&shared);
let dict = crate::jb2::decode_dict(&djbz, None).ok();
let djvi_body = jb2_encode::build_form_body(b"DJVI", &[(*b"Djbz", djbz)]);
comps.push((djvi_body, false, dict_id.to_string()));
dict
} else {
None
};
let build_page = |idx: usize,
pm: &Pixmap,
seg: &crate::segment::SegmentedPage|
-> Result<(Vec<u8>, bool, String), EncodeError> {
let w = u16::try_from(pm.width)
.map_err(|_| EncodeError::Unsupported("page width exceeds INFO chunk limit"))?;
let h = u16::try_from(pm.height)
.map_err(|_| EncodeError::Unsupported("page height exceeds INFO chunk limit"))?;
let sjbz = if has_shared {
jb2_encode::encode_jb2_dict_with_shared(&seg.mask, &shared)
} else {
jb2_encode::encode_jb2_dict(&seg.mask)
};
let fgbz = foreground_fgbz(
pm,
&seg.mask,
&sjbz,
shared_dict.as_ref(),
FgbzPaletteOptions::Exact,
);
let bg44_chunks = encode_iw44_color(&seg.bg, &Iw44EncodeOptions::default());
let mut chunks: Vec<([u8; 4], Vec<u8>)> = Vec::new();
chunks.push((*b"INFO", encode_info(w, h, dpi)));
if has_shared {
chunks.push((*b"INCL", dict_id.as_bytes().to_vec()));
}
chunks.push((*b"Sjbz", sjbz));
for body in bg44_chunks {
chunks.push((*b"BG44", body));
}
if let Some(chunk) = fgbz
&& let Chunk::Leaf { id, data } = chunk.into_leaf()
{
chunks.push((id, data));
}
if with_thumbnails {
let th44_payloads = crate::thumbnail::encode_th44_color(pm);
for payload in th44_payloads {
chunks.push((*b"TH44", payload));
}
}
let body = jb2_encode::build_form_body(b"DJVU", &chunks);
Ok((body, true, format!("p{:04}.djvu", idx + 1)))
};
#[cfg(feature = "parallel")]
let page_comps: Vec<(Vec<u8>, bool, String)> = {
use rayon::prelude::*;
pixmaps
.par_iter()
.zip(&segs)
.enumerate()
.map(|(idx, (pm, seg))| build_page(idx, pm, seg))
.collect::<Result<Vec<_>, _>>()?
};
#[cfg(not(feature = "parallel"))]
let page_comps: Vec<(Vec<u8>, bool, String)> = pixmaps
.iter()
.zip(&segs)
.enumerate()
.map(|(idx, (pm, seg))| build_page(idx, pm, seg))
.collect::<Result<Vec<_>, _>>()?;
comps.extend(page_comps);
Ok(jb2_encode::assemble_djvm_bundle(comps))
}
fn encode_form_djvu(children: Vec<Chunk>) -> Vec<u8> {
let file = DjvuFile {
root: Chunk::Form {
secondary_id: *b"DJVU",
length: 0, children,
},
};
emit(&file)
}
#[derive(Debug, Clone, Copy, Default)]
struct ColorAccum {
r: u64,
g: u64,
b: u64,
n: u64,
}
impl ColorAccum {
fn add(&mut self, r: u8, g: u8, b: u8) {
self.r += u64::from(r);
self.g += u64::from(g);
self.b += u64::from(b);
self.n += 1;
}
fn color(self) -> Option<FgbzColor> {
if self.n == 0 {
return None;
}
Some(FgbzColor {
r: (self.r / self.n) as u8,
g: (self.g / self.n) as u8,
b: (self.b / self.n) as u8,
})
}
}
fn foreground_fgbz(
pm: &Pixmap,
mask: &Bitmap,
sjbz: &[u8],
shared_dict: Option<&crate::jb2::Jb2Dict>,
palette_options: FgbzPaletteOptions,
) -> Option<EncodedChunk> {
let (decoded_mask, blit_map) = crate::jb2::decode_indexed(sjbz, shared_dict).ok()?;
if decoded_mask.width != mask.width || decoded_mask.height != mask.height {
return None;
}
let max_blit = blit_map.iter().copied().filter(|&i| i >= 0).max()? as usize;
let mut by_blit = vec![ColorAccum::default(); max_blit + 1];
let w = mask.width as usize;
let mstride = mask.row_stride();
for y in 0..mask.height as usize {
let mrow = &mask.data[y * mstride..(y + 1) * mstride];
let prow = &pm.data[y * w * 4..(y + 1) * w * 4];
let brow = &blit_map[y * w..(y + 1) * w];
for x in 0..w {
if (mrow[x >> 3] >> (7 - (x & 7))) & 1 != 0 {
let blit_idx = brow[x];
if blit_idx < 0 {
continue;
}
let px = &prow[x * 4..x * 4 + 3];
by_blit[blit_idx as usize].add(px[0], px[1], px[2]);
}
}
}
let (palette, indices): (Vec<FgbzColor>, Vec<i16>) = match palette_options {
FgbzPaletteOptions::Exact => {
let mut palette: Vec<FgbzColor> = Vec::new();
let mut indices: Vec<i16> = Vec::with_capacity(by_blit.len());
for accum in by_blit {
let color = accum.color().unwrap_or_default();
let color_idx = match palette.iter().position(|&c| c == color) {
Some(i) => i,
None => {
if palette.len() >= i16::MAX as usize {
return None;
}
palette.push(color);
palette.len() - 1
}
};
indices.push(color_idx as i16);
}
(palette, indices)
}
FgbzPaletteOptions::MedianCut { max_colors } => {
let blit_colors: Vec<FgbzColor> = by_blit
.iter()
.map(|accum| accum.color().unwrap_or_default())
.collect();
let weighted: Vec<WeightedColor> = by_blit
.iter()
.zip(&blit_colors)
.map(|(accum, &c)| WeightedColor {
r: c.r,
g: c.g,
b: c.b,
weight: accum.n,
})
.collect();
let palette = median_cut(&weighted, usize::from(max_colors.max(1)));
if palette.len() > i16::MAX as usize {
return None;
}
let indices: Vec<i16> = blit_colors
.iter()
.map(|&c| nearest_palette_index(&palette, c) as i16)
.collect();
(palette, indices)
}
};
if palette.is_empty() || palette.iter().all(|c| c.r == 0 && c.g == 0 && c.b == 0) {
return None;
}
let index_payload = if palette.len() > 1 {
Some(indices.as_slice())
} else {
None
};
FgbzChunk {
palette: &palette,
indices: index_payload,
}
.encode_chunk()
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::iff::parse_form;
use crate::jb2;
use crate::text::{TextZone, TextZoneKind};
fn checkerboard(w: u32, h: u32) -> Bitmap {
let mut bm = Bitmap::new(w, h);
for y in 0..h {
for x in 0..w {
if (x + y) % 2 == 0 {
bm.set_black(x, y);
}
}
}
bm
}
#[test]
fn default_segment_options_maps_archival_to_dense_background() {
assert_eq!(
EncodeQuality::Archival
.default_segment_options()
.bg_subsample,
6,
"Archival keeps a denser background grid"
);
assert_eq!(
EncodeQuality::Quality
.default_segment_options()
.bg_subsample,
SegmentOptions::default().bg_subsample,
);
assert_eq!(
EncodeQuality::Lossless
.default_segment_options()
.bg_subsample,
SegmentOptions::default().bg_subsample,
);
let arch = SegmentOptions::archival();
assert_eq!(arch.bg_subsample, 6);
assert_eq!(arch.threshold, SegmentOptions::default().threshold);
assert_eq!(arch.bg_inpaint, SegmentOptions::default().bg_inpaint);
}
#[test]
fn with_iw44_options_is_threaded_into_background_codec() {
let pm = mixed_lighting_fixture();
let default_bytes = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Quality)
.encode()
.expect("default encode");
let trimmed = Iw44EncodeOptions {
total_slices: 20,
..Iw44EncodeOptions::default()
};
let trimmed_bytes = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Quality)
.with_iw44_options(trimmed)
.encode()
.expect("trimmed encode");
assert!(
trimmed_bytes.len() < default_bytes.len(),
"with_iw44_options(total_slices=20) should shrink output ({} vs {})",
trimmed_bytes.len(),
default_bytes.len()
);
let doc = crate::djvu_document::DjVuDocument::parse(&trimmed_bytes).expect("parse");
assert!(!doc.page(0).expect("page").all_chunks(b"BG44").is_empty());
}
#[test]
fn with_jb2_options_lossy_threshold_round_trips() {
let pm = mixed_lighting_fixture();
let jb2 = Jb2EncodeOptions {
lossy_threshold: 0.04,
despeckle: None,
#[cfg(feature = "experimental")]
cross_size_rec6_probe: None,
#[cfg(feature = "experimental")]
same_size_rec6: None,
};
let bytes = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Quality)
.with_jb2_options(jb2)
.encode()
.expect("lossy jb2 encode");
let doc = crate::djvu_document::DjVuDocument::parse(&bytes).expect("parse");
let page = doc.page(0).expect("page");
assert!(page.raw_chunk(b"Sjbz").is_some());
page.extract_mask()
.expect("mask decode")
.expect("mask present");
}
#[test]
fn lossless_bilevel_round_trips() {
let bm = checkerboard(64, 48);
let bytes = PageEncoder::from_bitmap(&bm)
.with_dpi(150)
.with_quality(EncodeQuality::Lossless)
.encode()
.expect("encode");
let form = parse_form(&bytes).expect("parse_form");
assert_eq!(&form.form_type, b"DJVU");
let mut info_data: Option<&[u8]> = None;
let mut sjbz_data: Option<&[u8]> = None;
for chunk in &form.chunks {
match &chunk.id {
b"INFO" => info_data = Some(chunk.data),
b"Sjbz" => sjbz_data = Some(chunk.data),
_ => {}
}
}
let info = info_data.expect("INFO chunk present");
let sjbz = sjbz_data.expect("Sjbz chunk present");
assert_eq!(u16::from_be_bytes([info[0], info[1]]), 64);
assert_eq!(u16::from_be_bytes([info[2], info[3]]), 48);
assert_eq!(u16::from_le_bytes([info[6], info[7]]), 150);
let decoded = jb2::decode(sjbz, None).expect("jb2 decode");
assert_eq!(decoded.width, bm.width);
assert_eq!(decoded.height, bm.height);
for y in 0..bm.height {
for x in 0..bm.width {
assert_eq!(decoded.get(x, y), bm.get(x, y), "mismatch at ({x},{y})");
}
}
}
#[test]
fn defaults_are_300_dpi_lossless_for_bitmap() {
let bm = Bitmap::new(8, 8);
let enc = PageEncoder::from_bitmap(&bm);
assert_eq!(enc.dpi, 300);
assert_eq!(enc.quality, EncodeQuality::Lossless);
}
#[test]
fn defaults_are_300_dpi_quality_for_pixmap() {
let pm = Pixmap::white(8, 8);
let enc = PageEncoder::from_pixmap(&pm);
assert_eq!(enc.dpi, 300);
assert_eq!(enc.quality, EncodeQuality::Quality);
assert!(enc.segment_options.is_none());
}
#[test]
fn with_dpi_clamps_zero_to_one() {
let bm = Bitmap::new(8, 8);
let enc = PageEncoder::from_bitmap(&bm).with_dpi(0);
assert_eq!(enc.dpi, 1);
}
#[test]
fn archival_bitmap_rejected() {
let bm = Bitmap::new(16, 16);
let err = PageEncoder::from_bitmap(&bm)
.with_quality(EncodeQuality::Archival)
.encode()
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("Archival"));
}
#[test]
fn empty_bitmap_round_trips() {
let bm = Bitmap::new(1, 1);
let bytes = PageEncoder::from_bitmap(&bm).encode().expect("encode");
let form = parse_form(&bytes).expect("parse");
assert_eq!(&form.form_type, b"DJVU");
}
#[test]
fn encode_rejects_pixmap_width_exceeding_u16() {
let pm = Pixmap {
width: 70_000,
height: 1,
data: vec![],
};
let err = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Quality)
.encode()
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("width") || msg.contains("65"),
"unexpected: {msg}"
);
}
#[test]
fn encode_rejects_bitmap_height_exceeding_u16() {
let bm = Bitmap {
width: 1,
height: 70_000,
data: vec![0u8; 70_000 / 8 + 1],
};
let err = PageEncoder::from_bitmap(&bm)
.with_quality(EncodeQuality::Lossless)
.encode()
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("height") || msg.contains("65"),
"unexpected: {msg}"
);
}
#[test]
fn quality_color_emits_info_sjbz_bg44() {
let mut pm = Pixmap::white(64, 64);
for y in 16..32 {
for x in 16..32 {
pm.set_rgb(x, y, 0, 0, 0);
}
}
let bytes = PageEncoder::from_pixmap(&pm)
.with_dpi(200)
.with_quality(EncodeQuality::Quality)
.encode()
.expect("encode");
let form = parse_form(&bytes).expect("parse_form");
assert_eq!(&form.form_type, b"DJVU");
let mut has_info = false;
let mut has_sjbz = false;
let mut bg44_count = 0;
for chunk in &form.chunks {
match &chunk.id {
b"INFO" => has_info = true,
b"Sjbz" => has_sjbz = true,
b"BG44" => bg44_count += 1,
_ => {}
}
}
assert!(has_info, "INFO chunk missing");
assert!(has_sjbz, "Sjbz chunk missing");
assert!(
bg44_count > 0,
"expected at least one BG44 chunk, got {bg44_count}"
);
}
#[test]
fn quality_color_emits_fgbz_for_colored_foreground() {
let mut pm = Pixmap::white(64, 64);
for y in 16..32 {
for x in 16..32 {
pm.set_rgb(x, y, 180, 20, 20);
}
}
let bytes = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Quality)
.encode()
.expect("encode");
let form = parse_form(&bytes).expect("parse_form");
let fgbz = form
.chunks
.iter()
.find(|chunk| &chunk.id == b"FGbz")
.expect("FGbz chunk present");
let (palette, indices) = crate::fgbz_encode::decode_fgbz(fgbz.data).expect("decode FGbz");
assert_eq!(palette.len(), 1);
assert!(indices.is_empty());
assert!(palette[0].r > 0, "foreground red should be preserved");
}
#[test]
fn quality_color_emits_per_blit_fgbz_indices() {
let mut pm = Pixmap::white(80, 40);
for y in 8..24 {
for x in 8..24 {
pm.set_rgb(x, y, 180, 20, 20);
}
for x in 48..64 {
pm.set_rgb(x, y, 20, 40, 180);
}
}
let bytes = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Quality)
.encode()
.expect("encode");
let doc = crate::djvu_document::DjVuDocument::parse(&bytes).expect("parse");
let page = doc.page(0).expect("page");
let fgbz = page.raw_chunk(b"FGbz").expect("FGbz present");
let (palette, indices) = crate::fgbz_encode::decode_fgbz(fgbz).expect("decode FGbz");
assert!(
palette.len() >= 2,
"expected at least two foreground colors, got {palette:?}"
);
assert!(
indices.len() >= 2,
"expected per-blit indices for two foreground components"
);
assert_ne!(
indices[0], indices[1],
"separate colored components should point at distinct palette entries"
);
let rendered = crate::Document::from_bytes(bytes)
.expect("document")
.page(0)
.expect("page")
.render()
.expect("render");
let left = rendered.get_rgb(12, 12);
let right = rendered.get_rgb(52, 12);
assert!(
left.0 > left.2,
"left foreground should render red-dominant, got {left:?}"
);
assert!(
right.2 > right.0,
"right foreground should render blue-dominant, got {right:?}"
);
}
#[test]
fn median_cut_reduces_many_near_duplicate_colors_to_k() {
let mut colors = Vec::new();
for i in 0..20u8 {
colors.push(WeightedColor {
r: 180 + (i % 5),
g: 20,
b: 20,
weight: 10,
});
}
for i in 0..20u8 {
colors.push(WeightedColor {
r: 20,
g: 20,
b: 180 + (i % 5),
weight: 10,
});
}
let palette = median_cut(&colors, 2);
assert_eq!(palette.len(), 2);
let (mut reds, mut blues) = (0, 0);
for c in &palette {
if c.r > c.b {
reds += 1;
} else {
blues += 1;
}
}
assert_eq!((reds, blues), (1, 1));
}
#[test]
fn median_cut_never_exceeds_k_even_with_fewer_distinct_colors() {
let colors = vec![
WeightedColor {
r: 10,
g: 10,
b: 10,
weight: 1,
},
WeightedColor {
r: 10,
g: 10,
b: 10,
weight: 1,
},
];
let palette = median_cut(&colors, 8);
assert_eq!(palette.len(), 1);
}
#[test]
fn median_cut_empty_input_is_empty() {
assert!(median_cut(&[], 4).is_empty());
}
#[test]
fn nearest_palette_index_picks_closest() {
let palette = [
FgbzColor { r: 255, g: 0, b: 0 },
FgbzColor { r: 0, g: 0, b: 255 },
];
assert_eq!(
nearest_palette_index(
&palette,
FgbzColor {
r: 200,
g: 10,
b: 10
}
),
0
);
assert_eq!(
nearest_palette_index(
&palette,
FgbzColor {
r: 10,
g: 10,
b: 200
}
),
1
);
}
#[test]
fn fgbz_mediancut_is_opt_in_default_stays_exact() {
let mut pm = Pixmap::white(80, 40);
for y in 8..24 {
for x in 8..24 {
pm.set_rgb(x, y, 180, 20, 20);
}
for x in 48..64 {
pm.set_rgb(x, y, 20, 40, 180);
}
}
let default_bytes = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Quality)
.encode()
.expect("default encode");
let explicit_exact_bytes = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Quality)
.with_fgbz_options(FgbzPaletteOptions::Exact)
.encode()
.expect("exact encode");
assert_eq!(
default_bytes, explicit_exact_bytes,
"FgbzPaletteOptions::Exact must be byte-identical to the (opt-out) default"
);
let capped_bytes = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Quality)
.with_fgbz_options(FgbzPaletteOptions::MedianCut { max_colors: 1 })
.encode()
.expect("median-cut encode");
assert_ne!(
default_bytes, capped_bytes,
"opting into MedianCut{{max_colors:1}} must change the output"
);
let doc = crate::djvu_document::DjVuDocument::parse(&capped_bytes).expect("parse");
let page = doc.page(0).expect("page");
let fgbz = page.raw_chunk(b"FGbz").expect("FGbz present");
let (palette, _indices) = crate::fgbz_encode::decode_fgbz(fgbz).expect("decode FGbz");
assert_eq!(palette.len(), 1, "capped at 1 palette entry");
}
#[test]
fn quality_color_accepts_adaptive_segment_options() {
let pm = mixed_lighting_fixture();
let bytes = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Quality)
.with_segment_options(adaptive_segment_options())
.encode()
.expect("encode");
let doc = crate::djvu_document::DjVuDocument::parse(&bytes).expect("parse");
let page = doc.page(0).expect("page");
assert!(page.raw_chunk(b"Sjbz").is_some());
assert!(!page.all_chunks(b"BG44").is_empty());
}
#[test]
fn layered_shared_djbz_round_trips_with_incl() {
let pm = mixed_lighting_fixture();
let pages = [pm.clone(), pm.clone()];
let bytes = encode_djvm_layered_shared(&pages, EncodeQuality::Quality, 300, None, 2)
.expect("layered shared encode");
let doc = crate::djvu_document::DjVuDocument::parse(&bytes).expect("parse bundle");
assert_eq!(doc.page_count(), 2);
for i in 0..2 {
let page = doc.page(i).expect("page");
assert!(page.raw_chunk(b"Sjbz").is_some(), "page {i} Sjbz");
assert!(!page.all_chunks(b"BG44").is_empty(), "page {i} BG44");
assert!(
page.raw_chunk(b"INCL").is_some(),
"page {i} INCL → shared dict"
);
page.extract_mask()
.expect("mask decode")
.expect("mask present");
}
assert!(
bytes.windows(4).any(|w| w == b"Djbz"),
"shared Djbz form present"
);
}
#[test]
fn adaptive_segment_options_improve_decoded_mixed_lighting_fixture() {
let pm = mixed_lighting_fixture();
let fixed = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Quality)
.with_segment_options(SegmentOptions {
bg_subsample: 6,
..SegmentOptions::default()
})
.encode()
.expect("fixed encode");
let adaptive = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Quality)
.with_segment_options(SegmentOptions {
bg_subsample: 6,
..adaptive_segment_options()
})
.encode()
.expect("adaptive encode");
let fixed_render = render_encoded(&fixed);
let adaptive_render = render_encoded(&adaptive);
let fixed_err = mean_abs_rgb_diff(&pm, &fixed_render);
let adaptive_err = mean_abs_rgb_diff(&pm, &adaptive_render);
assert!(
adaptive_err < fixed_err * 0.70,
"adaptive decoded render should be closer to source ({adaptive_err:.2} vs {fixed_err:.2})"
);
}
fn adaptive_segment_options() -> SegmentOptions {
SegmentOptions {
binarization: crate::segment::Binarization::Sauvola { window: 9, k: 0.34 },
bg_inpaint: true,
..SegmentOptions::default()
}
}
fn mixed_lighting_fixture() -> Pixmap {
let mut pm = Pixmap::white(48, 24);
for y in 0..24 {
for x in 0..48 {
let v = if x < 24 { 80 } else { 220 };
pm.set_rgb(x, y, v, v, v);
}
}
for y in 6..18 {
pm.set_rgb(9, y, 40, 40, 40);
pm.set_rgb(14, y, 40, 40, 40);
}
for x in 9..=14 {
pm.set_rgb(x, 6, 40, 40, 40);
pm.set_rgb(x, 12, 40, 40, 40);
}
for y in 6..18 {
pm.set_rgb(33, y, 140, 140, 140);
pm.set_rgb(40, y, 140, 140, 140);
}
for x in 33..=40 {
pm.set_rgb(x, 6, 140, 140, 140);
pm.set_rgb(x, 12, 140, 140, 140);
pm.set_rgb(x, 17, 140, 140, 140);
}
pm
}
fn render_encoded(bytes: &[u8]) -> Pixmap {
let doc = crate::djvu_document::DjVuDocument::parse(bytes).expect("parse encoded doc");
let page = doc.page(0).expect("page");
let (width, height) = page.dimensions();
let opts = crate::djvu_render::RenderOptions {
width: u32::from(width),
height: u32::from(height),
..crate::djvu_render::RenderOptions::default()
};
crate::djvu_render::render_pixmap(page, &opts).expect("render encoded page")
}
fn mean_abs_rgb_diff(expected: &Pixmap, actual: &Pixmap) -> f64 {
assert_eq!(
(expected.width, expected.height),
(actual.width, actual.height)
);
let mut sum = 0u64;
let mut n = 0u64;
for (a, b) in expected
.data
.chunks_exact(4)
.zip(actual.data.chunks_exact(4))
{
for c in 0..3 {
sum += a[c].abs_diff(b[c]) as u64;
n += 1;
}
}
sum as f64 / n as f64
}
#[test]
fn archival_color_emits_layered_djvu_with_fgbz() {
let mut pm = Pixmap::white(48, 48);
for y in 12..24 {
for x in 12..24 {
pm.set_rgb(x, y, 0, 90, 180);
}
}
let bytes = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Archival)
.encode()
.expect("encode");
let doc = crate::djvu_document::DjVuDocument::parse(&bytes).expect("parse");
let page = doc.page(0).expect("page");
assert!(page.raw_chunk(b"Sjbz").is_some());
assert!(!page.all_chunks(b"BG44").is_empty());
assert!(page.raw_chunk(b"FGbz").is_some());
}
#[test]
fn lossless_pixmap_rejected() {
let pm = Pixmap::white(8, 8);
let err = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Lossless)
.encode()
.unwrap_err();
assert!(format!("{err}").contains("Lossless"));
}
#[test]
fn quality_bitmap_rejected() {
let bm = Bitmap::new(8, 8);
let err = PageEncoder::from_bitmap(&bm)
.with_quality(EncodeQuality::Quality)
.encode()
.unwrap_err();
assert!(format!("{err}").contains("Quality"));
}
#[test]
fn quality_color_round_trips_through_document() {
let pm = Pixmap::white(32, 24);
let bytes = PageEncoder::from_pixmap(&pm)
.with_dpi(150)
.with_quality(EncodeQuality::Quality)
.encode()
.expect("encode");
let doc = crate::djvu_document::DjVuDocument::parse(&bytes).expect("parse");
let page = doc.page(0).expect("page 0");
assert_eq!(page.width(), 32);
assert_eq!(page.height(), 24);
assert_eq!(page.dpi(), 150);
assert!(page.raw_chunk(b"Sjbz").is_some());
assert!(!page.all_chunks(b"BG44").is_empty());
}
#[test]
fn layered_bundle_with_thumbnails_each_page_has_th44() {
let mut p1 = Pixmap::white(64, 48);
for y in 8..24 {
for x in 8..24 {
p1.set_rgb(x, y, 180, 20, 20);
}
}
let p2 = Pixmap::white(64, 48);
let bytes = encode_djvm_layered_shared_with_thumbnails(
&[p1.clone(), p2.clone()],
EncodeQuality::Quality,
300,
None,
2,
true,
)
.expect("encode layered with thumbnails");
let doc = crate::djvu_document::DjVuDocument::parse(&bytes).expect("parse bundle");
assert_eq!(doc.page_count(), 2);
for i in 0..2 {
let page = doc.page(i).expect("page");
let thumb = page.thumbnail().expect("thumbnail() should not error");
assert!(
thumb.is_some(),
"page {i} must carry a TH44 thumbnail when with_thumbnails=true"
);
let thumb = thumb.unwrap();
let (tw, th) = crate::thumbnail::thumbnail_dimensions(
if i == 0 { p1.width } else { p2.width },
if i == 0 { p1.height } else { p2.height },
);
assert_eq!(
thumb.width, tw,
"page {i} thumbnail width should be {tw}, got {}",
thumb.width
);
assert_eq!(
thumb.height, th,
"page {i} thumbnail height should be {th}, got {}",
thumb.height
);
}
}
#[test]
fn layered_bundle_without_thumbnails_has_no_th44() {
let pm = Pixmap::white(64, 48);
let bytes =
encode_djvm_layered_shared(&[pm.clone(), pm], EncodeQuality::Quality, 300, None, 2)
.expect("encode layered");
let doc = crate::djvu_document::DjVuDocument::parse(&bytes).expect("parse bundle");
assert_eq!(doc.page_count(), 2);
for i in 0..2 {
let page = doc.page(i).expect("page");
let thumb = page.thumbnail().expect("thumbnail() should not error");
assert!(
thumb.is_none(),
"page {i} must NOT carry a TH44 thumbnail when with_thumbnails=false"
);
}
}
fn sample_text_layer(page_w: u32, page_h: u32) -> TextLayer {
use crate::text::Rect;
TextLayer {
text: "Hello World".into(),
zones: vec![TextZone {
kind: TextZoneKind::Page,
rect: Rect {
x: 0,
y: 0,
width: page_w,
height: page_h,
},
text: "Hello World".into(),
children: vec![TextZone {
kind: TextZoneKind::Line,
rect: Rect {
x: 4,
y: 6,
width: page_w.saturating_sub(8),
height: 20,
},
text: "Hello World".into(),
children: vec![
TextZone {
kind: TextZoneKind::Word,
rect: Rect {
x: 4,
y: 6,
width: 50,
height: 20,
},
text: "Hello".into(),
children: vec![],
},
TextZone {
kind: TextZoneKind::Word,
rect: Rect {
x: 60,
y: 6,
width: 50,
height: 20,
},
text: "World".into(),
children: vec![],
},
],
}],
}],
}
}
#[test]
fn no_text_layer_is_byte_identical_to_pre_txtz_ocr_baseline() {
let bm = checkerboard(32, 24);
let bytes = PageEncoder::from_bitmap(&bm).encode().expect("encode");
let form = parse_form(&bytes).expect("parse_form");
assert!(
!form
.chunks
.iter()
.any(|c| &c.id == b"TXTz" || &c.id == b"TXTa"),
"no text layer attached => no TXTz/TXTa chunk should be emitted"
);
}
#[test]
fn with_text_layer_emits_txtz_and_round_trips_through_our_decoder() {
let bm = checkerboard(120, 80);
let layer = sample_text_layer(120, 80);
let bytes = PageEncoder::from_bitmap(&bm)
.with_quality(EncodeQuality::Lossless)
.with_text_layer(layer.clone())
.encode()
.expect("encode");
let form = parse_form(&bytes).expect("parse_form");
assert!(
form.chunks.iter().any(|c| &c.id == b"TXTz"),
"TXTz chunk should be present after with_text_layer"
);
let doc = crate::djvu_document::DjVuDocument::parse(&bytes).expect("parse document");
let page = doc.page(0).expect("page 0");
let decoded = page
.text_layer()
.expect("text_layer() must not error")
.expect("text_layer() must return Some after with_text_layer");
assert_eq!(decoded.text, "Hello World");
let words: Vec<&str> = decoded
.zones
.first()
.and_then(|page_zone| page_zone.children.first())
.map(|line| line.children.iter().map(|w| w.text.as_str()).collect())
.unwrap_or_default();
assert_eq!(words, vec!["Hello", "World"]);
let plain = page.text().expect("text()").expect("Some plain text");
assert_eq!(plain, "Hello World");
}
struct MockOcrBackend {
layer: TextLayer,
}
impl OcrBackend for MockOcrBackend {
fn recognize(
&self,
_pixmap: &Pixmap,
_options: &OcrOptions,
) -> Result<TextLayer, OcrError> {
Ok(self.layer.clone())
}
}
#[test]
fn with_ocr_text_layer_runs_backend_and_attaches_result() {
let bm = checkerboard(96, 64);
let backend = MockOcrBackend {
layer: sample_text_layer(96, 64),
};
let bytes = PageEncoder::from_bitmap(&bm)
.with_quality(EncodeQuality::Lossless)
.with_ocr_text_layer(&backend, &OcrOptions::default())
.expect("OCR backend should not fail")
.encode()
.expect("encode");
let doc = crate::djvu_document::DjVuDocument::parse(&bytes).expect("parse document");
let page = doc.page(0).expect("page 0");
let text = page.text().expect("text()").expect("Some plain text");
assert_eq!(text, "Hello World");
}
#[test]
fn with_ocr_text_layer_works_from_colour_pixmap_source_too() {
let pm = Pixmap::white(96, 64);
let backend = MockOcrBackend {
layer: sample_text_layer(96, 64),
};
let bytes = PageEncoder::from_pixmap(&pm)
.with_quality(EncodeQuality::Quality)
.with_ocr_text_layer(&backend, &OcrOptions::default())
.expect("OCR backend should not fail")
.encode()
.expect("encode");
let form = parse_form(&bytes).expect("parse_form");
assert!(form.chunks.iter().any(|c| &c.id == b"TXTz"));
}
#[test]
fn bitmap_to_pixmap_maps_black_pixels_to_black_rgb() {
let mut bm = Bitmap::new(4, 2);
bm.set_black(1, 0);
let pm = bitmap_to_pixmap(&bm);
assert_eq!(pm.get_rgb(1, 0), (0, 0, 0));
assert_eq!(pm.get_rgb(0, 0), (255, 255, 255));
}
}