use std::path::Path;
use std::process::ExitCode;
use djvu_rs::{
Bitmap, DjVuDocument, jb2,
jb2_encode::{Jb2EncodeOptions, encode_jb2, encode_jb2_dict, encode_jb2_dict_with_options},
};
#[derive(Copy, Clone, Debug)]
enum RoundtripStatus {
Ok,
Mismatch,
DecodeError,
}
impl RoundtripStatus {
fn as_str(&self) -> &'static str {
match self {
RoundtripStatus::Ok => "ok",
RoundtripStatus::Mismatch => "mismatch",
RoundtripStatus::DecodeError => "decode_error",
}
}
}
struct PageResult {
file: String,
page: usize,
width: u32,
height: u32,
orig_sjbz_bytes: usize,
rs_sjbz_bytes: usize,
rs_dict_sjbz_bytes: usize,
rs_lossy_sjbz_bytes: usize,
lossy_hamming: u64,
roundtrip: RoundtripStatus,
roundtrip_dict: RoundtripStatus,
}
fn bitmap_hamming(a: &Bitmap, b: &Bitmap) -> u64 {
if a.width != b.width || a.height != b.height || a.data.len() != b.data.len() {
return u64::MAX;
}
let mut total: u64 = 0;
for (x, y) in a.data.iter().zip(b.data.iter()) {
total += (x ^ y).count_ones() as u64;
}
total
}
fn process_file(path: &Path, lossy_threshold: f32) -> Vec<PageResult> {
let data = match std::fs::read(path) {
Ok(d) => d,
Err(e) => {
eprintln!("skip {}: {}", path.display(), e);
return vec![];
}
};
let doc = match DjVuDocument::parse(&data) {
Ok(d) => d,
Err(e) => {
eprintln!("skip {}: parse failed: {}", path.display(), e);
return vec![];
}
};
let mut out = Vec::new();
for page_idx in 0..doc.page_count() {
let page = match doc.page(page_idx) {
Ok(p) => p,
Err(_) => continue,
};
let orig_sjbz = match page.raw_chunk(b"Sjbz") {
Some(s) => s,
None => continue,
};
let bitmap = match page.extract_mask() {
Ok(Some(b)) => b,
_ => continue,
};
let rs_encoded = encode_jb2(&bitmap);
let rs_dict_encoded = encode_jb2_dict(&bitmap);
#[allow(clippy::needless_update)]
let rs_lossy_encoded = if lossy_threshold > 0.0 {
encode_jb2_dict_with_options(
&bitmap,
&[],
&Jb2EncodeOptions {
lossy_threshold,
..Default::default()
},
)
} else {
rs_dict_encoded.clone()
};
let lossy_hamming = if lossy_threshold > 0.0 {
match jb2::decode(&rs_lossy_encoded, None) {
Ok(d) => bitmap_hamming(&bitmap, &d),
Err(_) => u64::MAX,
}
} else {
0
};
let roundtrip = match jb2::decode(&rs_encoded, None) {
Ok(b) => {
if b.width == bitmap.width && b.height == bitmap.height && b.data == bitmap.data {
RoundtripStatus::Ok
} else {
RoundtripStatus::Mismatch
}
}
Err(_) => RoundtripStatus::DecodeError,
};
let roundtrip_dict = match jb2::decode(&rs_dict_encoded, None) {
Ok(b) => {
if b.width == bitmap.width && b.height == bitmap.height && b.data == bitmap.data {
RoundtripStatus::Ok
} else {
RoundtripStatus::Mismatch
}
}
Err(_) => RoundtripStatus::DecodeError,
};
out.push(PageResult {
file: path.display().to_string(),
page: page_idx,
width: bitmap.width,
height: bitmap.height,
orig_sjbz_bytes: orig_sjbz.len(),
rs_sjbz_bytes: rs_encoded.len(),
rs_dict_sjbz_bytes: rs_dict_encoded.len(),
rs_lossy_sjbz_bytes: rs_lossy_encoded.len(),
lossy_hamming,
roundtrip,
roundtrip_dict,
});
}
out
}
fn bpp(bytes: usize, width: u32, height: u32) -> f64 {
if width == 0 || height == 0 {
return f64::NAN;
}
(bytes as f64 * 8.0) / (width as f64 * height as f64)
}
fn main() -> ExitCode {
let raw_args: Vec<String> = std::env::args().skip(1).collect();
let mut lossy_threshold: f32 = 0.0;
let mut files: Vec<String> = Vec::new();
let mut i = 0;
while i < raw_args.len() {
let a = &raw_args[i];
if a == "--lossy-threshold" {
i += 1;
if i >= raw_args.len() {
eprintln!("--lossy-threshold requires a value (e.g. 0.04)");
return ExitCode::from(2);
}
lossy_threshold = match raw_args[i].parse::<f32>() {
Ok(v) if (0.0..=1.0).contains(&v) => v,
_ => {
eprintln!(
"--lossy-threshold must be a fraction in [0.0, 1.0] (got {:?})",
raw_args[i]
);
return ExitCode::from(2);
}
};
} else if let Some(rest) = a.strip_prefix("--lossy-threshold=") {
lossy_threshold = match rest.parse::<f32>() {
Ok(v) if (0.0..=1.0).contains(&v) => v,
_ => {
eprintln!(
"--lossy-threshold must be a fraction in [0.0, 1.0] (got {:?})",
rest
);
return ExitCode::from(2);
}
};
} else {
files.push(a.clone());
}
i += 1;
}
if files.is_empty() {
eprintln!(
"usage: encode_quality_jb2 [--lossy-threshold <fraction>] <file.djvu> [<file2.djvu> ...]\n\
\n\
Re-encodes every Sjbz chunk via djvu-rs and compares size to the\n\
original. Also verifies lossless round-trip.\n\
\n\
--lossy-threshold <f> enable lossy rec-7 substitution at the given\n\
Hamming fraction (e.g. 0.04 = 4%% of pixels).\n\
Default 0 = lossless."
);
return ExitCode::from(2);
}
let mut all: Vec<PageResult> = Vec::new();
for arg in &files {
let path = Path::new(arg);
all.extend(process_file(path, lossy_threshold));
}
for r in &all {
println!(
"{{\"file\":\"{}\",\"page\":{},\"width\":{},\"height\":{},\
\"orig_sjbz_bytes\":{},\"rs_sjbz_bytes\":{},\"rs_dict_sjbz_bytes\":{},\
\"rs_lossy_sjbz_bytes\":{},\"lossy_hamming\":{},\
\"bpp_orig\":{:.4},\"bpp_rs\":{:.4},\"bpp_rs_dict\":{:.4},\"bpp_rs_lossy\":{:.4},\
\"size_ratio\":{:.3},\"size_ratio_dict\":{:.3},\"size_ratio_lossy\":{:.3},\
\"roundtrip\":\"{}\",\"roundtrip_dict\":\"{}\"}}",
r.file,
r.page,
r.width,
r.height,
r.orig_sjbz_bytes,
r.rs_sjbz_bytes,
r.rs_dict_sjbz_bytes,
r.rs_lossy_sjbz_bytes,
r.lossy_hamming,
bpp(r.orig_sjbz_bytes, r.width, r.height),
bpp(r.rs_sjbz_bytes, r.width, r.height),
bpp(r.rs_dict_sjbz_bytes, r.width, r.height),
bpp(r.rs_lossy_sjbz_bytes, r.width, r.height),
r.rs_sjbz_bytes as f64 / r.orig_sjbz_bytes.max(1) as f64,
r.rs_dict_sjbz_bytes as f64 / r.orig_sjbz_bytes.max(1) as f64,
r.rs_lossy_sjbz_bytes as f64 / r.orig_sjbz_bytes.max(1) as f64,
r.roundtrip.as_str(),
r.roundtrip_dict.as_str(),
);
}
if all.is_empty() {
eprintln!("no JB2 pages processed");
return ExitCode::from(1);
}
let total_orig: usize = all.iter().map(|r| r.orig_sjbz_bytes).sum();
let total_rs: usize = all.iter().map(|r| r.rs_sjbz_bytes).sum();
let total_rs_dict: usize = all.iter().map(|r| r.rs_dict_sjbz_bytes).sum();
let total_pixels: u64 = all.iter().map(|r| r.width as u64 * r.height as u64).sum();
let roundtrip_ok = all
.iter()
.filter(|r| matches!(r.roundtrip, RoundtripStatus::Ok))
.count();
let roundtrip_mismatch = all
.iter()
.filter(|r| matches!(r.roundtrip, RoundtripStatus::Mismatch))
.count();
let roundtrip_decode_err = all
.iter()
.filter(|r| matches!(r.roundtrip, RoundtripStatus::DecodeError))
.count();
let roundtrip_dict_ok = all
.iter()
.filter(|r| matches!(r.roundtrip_dict, RoundtripStatus::Ok))
.count();
let roundtrip_dict_mismatch = all
.iter()
.filter(|r| matches!(r.roundtrip_dict, RoundtripStatus::Mismatch))
.count();
let roundtrip_dict_decode_err = all
.iter()
.filter(|r| matches!(r.roundtrip_dict, RoundtripStatus::DecodeError))
.count();
eprintln!();
eprintln!("=== JB2 encoder quality summary ===");
eprintln!("pages: {}", all.len());
eprintln!();
eprintln!("-- direct (record type 3, whole image) --");
eprintln!("roundtrip ok: {}", roundtrip_ok);
eprintln!("roundtrip mismatch: {}", roundtrip_mismatch);
eprintln!(
"roundtrip decode err: {} (issue #198: >1 MP single-symbol)",
roundtrip_decode_err
);
eprintln!(
"total rs size: {:>10} bytes ({:.4} bpp)",
total_rs,
(total_rs as f64 * 8.0) / total_pixels.max(1) as f64
);
eprintln!(
"ratio rs / orig: {:.3}×",
total_rs as f64 / total_orig.max(1) as f64
);
eprintln!();
eprintln!("-- dict (CC + rec types 1+7) --");
eprintln!("roundtrip ok: {}", roundtrip_dict_ok);
eprintln!("roundtrip mismatch: {}", roundtrip_dict_mismatch);
eprintln!("roundtrip decode err: {}", roundtrip_dict_decode_err);
eprintln!(
"total rs-dict size: {:>10} bytes ({:.4} bpp)",
total_rs_dict,
(total_rs_dict as f64 * 8.0) / total_pixels.max(1) as f64
);
eprintln!(
"ratio rs-dict / orig: {:.3}× (>1 = djvu-rs encoder worse)",
total_rs_dict as f64 / total_orig.max(1) as f64
);
eprintln!();
eprintln!(
"total orig size: {:>10} bytes ({:.4} bpp)",
total_orig,
(total_orig as f64 * 8.0) / total_pixels.max(1) as f64
);
if lossy_threshold > 0.0 {
let total_rs_lossy: usize = all.iter().map(|r| r.rs_lossy_sjbz_bytes).sum();
let total_lossy_hamming: u64 = all
.iter()
.map(|r| {
if r.lossy_hamming == u64::MAX {
0
} else {
r.lossy_hamming
}
})
.sum();
let lossy_decode_err = all.iter().filter(|r| r.lossy_hamming == u64::MAX).count();
eprintln!();
eprintln!(
"-- lossy dict (rec-7 near-dup substitution, threshold = {:.4}) --",
lossy_threshold
);
eprintln!(
"total rs-lossy size: {:>10} bytes ({:.4} bpp)",
total_rs_lossy,
(total_rs_lossy as f64 * 8.0) / total_pixels.max(1) as f64
);
eprintln!(
"ratio rs-lossy / orig: {:.3}×",
total_rs_lossy as f64 / total_orig.max(1) as f64
);
eprintln!(
"ratio rs-lossy / rs-dict: {:.3}×",
total_rs_lossy as f64 / total_rs_dict.max(1) as f64
);
eprintln!(
"total reconstruction err: {} pixels ({:.6} bits/pixel error)",
total_lossy_hamming,
total_lossy_hamming as f64 / total_pixels.max(1) as f64
);
eprintln!("lossy decode errors: {}", lossy_decode_err);
}
if roundtrip_mismatch > 0 || roundtrip_dict_mismatch > 0 {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
}
}