use std::fmt;
use clap::Parser;
#[derive(Debug)]
pub enum CliError {
InvalidArgs(String),
Image(String),
Io(std::io::Error),
}
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CliError::InvalidArgs(msg) => write!(f, "invalid arguments: {msg}"),
CliError::Image(msg) => write!(f, "image error: {msg}"),
CliError::Io(e) => write!(f, "I/O error: {e}"),
}
}
}
impl std::error::Error for CliError {}
#[derive(Parser, Debug)]
#[command(name = "jbig2", about = "JBIG2 encoder - Rust port of jbig2enc")]
pub struct Args {
#[arg(short = 'b', default_value = "output")]
pub basename: String,
#[arg(short = 'd', long = "duplicate-line-removal")]
pub duplicate_line_removal: bool,
#[arg(short = 'p', long = "pdf")]
pub pdf: bool,
#[arg(short = 's', long = "symbol-mode")]
pub symbol_mode: bool,
#[arg(short = 't', default_value_t = 0.92)]
pub threshold: f32,
#[arg(short = 'w', default_value_t = 0.5)]
pub weight: f32,
#[arg(short = 'T')]
pub bw_threshold: Option<u8>,
#[arg(short = 'G', long = "global")]
pub global: bool,
#[arg(short = 'r', long = "refine")]
pub refine: bool,
#[arg(short = 'O')]
pub output_threshold: Option<String>,
#[arg(short = '2')]
pub up2: bool,
#[arg(short = '4')]
pub up4: bool,
#[arg(short = 'S')]
pub segment: bool,
#[arg(short = 'j', long = "jpeg-output")]
pub jpeg_output: bool,
#[arg(short = 'a', long = "auto-thresh")]
pub auto_thresh: bool,
#[arg(long = "no-hash")]
pub no_hash: bool,
#[arg(short = 'D', long = "dpi")]
pub dpi: Option<u32>,
#[arg(short = 'v')]
pub verbose: bool,
#[arg(required = true)]
pub files: Vec<String>,
}
impl Args {
pub fn validate(&self) -> Result<(), CliError> {
if self.refine {
if !self.symbol_mode {
return Err(CliError::InvalidArgs(
"refinement requires symbol mode (-s)".into(),
));
}
return Err(CliError::InvalidArgs(
"refinement broke in recent releases since it's rarely used".into(),
));
}
if self.up2 && self.up4 {
return Err(CliError::InvalidArgs("cannot use both -2 and -4".into()));
}
if !(0.4..=0.97).contains(&self.threshold) {
return Err(CliError::InvalidArgs(format!(
"threshold must be between 0.40 and 0.97, got {}",
self.threshold
)));
}
if !(0.1..=0.9).contains(&self.weight) {
return Err(CliError::InvalidArgs(format!(
"weight must be between 0.10 and 0.90, got {}",
self.weight
)));
}
if let Some(dpi) = self.dpi
&& !(1..=9600).contains(&dpi)
{
return Err(CliError::InvalidArgs(format!(
"DPI must be between 1 and 9600, got {dpi}"
)));
}
Ok(())
}
pub fn effective_bw_threshold(&self) -> u8 {
self.bw_threshold
.unwrap_or(if self.global { 128 } else { 200 })
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_args() -> Args {
Args {
basename: "output".to_string(),
duplicate_line_removal: false,
pdf: false,
symbol_mode: false,
threshold: 0.92,
weight: 0.5,
bw_threshold: None,
global: false,
refine: false,
output_threshold: None,
up2: false,
up4: false,
segment: false,
jpeg_output: false,
auto_thresh: false,
no_hash: false,
dpi: None,
verbose: false,
files: vec!["input.png".to_string()],
}
}
#[test]
fn default_args_pass_validation() {
let args = default_args();
assert!(args.validate().is_ok());
}
#[test]
fn default_bw_threshold_is_200() {
let args = default_args();
assert_eq!(args.effective_bw_threshold(), 200);
}
#[test]
fn global_mode_bw_threshold_is_128() {
let args = Args {
global: true,
..default_args()
};
assert_eq!(args.effective_bw_threshold(), 128);
}
#[test]
fn explicit_bw_threshold_overrides_default() {
let args = Args {
bw_threshold: Some(150),
..default_args()
};
assert_eq!(args.effective_bw_threshold(), 150);
}
#[test]
fn explicit_bw_threshold_overrides_global_default() {
let args = Args {
bw_threshold: Some(150),
global: true,
..default_args()
};
assert_eq!(args.effective_bw_threshold(), 150);
}
#[test]
fn threshold_at_min_boundary_is_accepted() {
let args = Args {
threshold: 0.4,
..default_args()
};
assert!(args.validate().is_ok());
}
#[test]
fn threshold_at_max_boundary_is_accepted() {
let args = Args {
threshold: 0.97,
..default_args()
};
assert!(args.validate().is_ok());
}
#[test]
fn threshold_below_min_is_rejected() {
let args = Args {
threshold: 0.39,
..default_args()
};
let err = args.validate().unwrap_err();
assert!(matches!(err, CliError::InvalidArgs(_)));
}
#[test]
fn threshold_above_max_is_rejected() {
let args = Args {
threshold: 0.98,
..default_args()
};
let err = args.validate().unwrap_err();
assert!(matches!(err, CliError::InvalidArgs(_)));
}
#[test]
fn weight_at_min_boundary_is_accepted() {
let args = Args {
weight: 0.1,
..default_args()
};
assert!(args.validate().is_ok());
}
#[test]
fn weight_at_max_boundary_is_accepted() {
let args = Args {
weight: 0.9,
..default_args()
};
assert!(args.validate().is_ok());
}
#[test]
fn weight_below_min_is_rejected() {
let args = Args {
weight: 0.09,
..default_args()
};
let err = args.validate().unwrap_err();
assert!(matches!(err, CliError::InvalidArgs(_)));
}
#[test]
fn weight_above_max_is_rejected() {
let args = Args {
weight: 0.91,
..default_args()
};
let err = args.validate().unwrap_err();
assert!(matches!(err, CliError::InvalidArgs(_)));
}
#[test]
fn up2_and_up4_are_exclusive() {
let args = Args {
up2: true,
up4: true,
..default_args()
};
let err = args.validate().unwrap_err();
assert!(matches!(err, CliError::InvalidArgs(_)));
}
#[test]
fn up2_alone_is_accepted() {
let args = Args {
up2: true,
..default_args()
};
assert!(args.validate().is_ok());
}
#[test]
fn up4_alone_is_accepted() {
let args = Args {
up4: true,
..default_args()
};
assert!(args.validate().is_ok());
}
#[test]
fn refine_without_symbol_mode_is_rejected() {
let args = Args {
refine: true,
symbol_mode: false,
..default_args()
};
let err = args.validate().unwrap_err();
assert!(matches!(err, CliError::InvalidArgs(_)));
}
#[test]
fn refine_with_symbol_mode_is_rejected_as_broken() {
let args = Args {
refine: true,
symbol_mode: true,
..default_args()
};
let err = args.validate().unwrap_err();
let msg = err.to_string();
assert!(msg.contains("broke"), "expected 'broken' error, got: {msg}");
}
#[test]
fn dpi_at_min_boundary_is_accepted() {
let args = Args {
dpi: Some(1),
..default_args()
};
assert!(args.validate().is_ok());
}
#[test]
fn dpi_at_max_boundary_is_accepted() {
let args = Args {
dpi: Some(9600),
..default_args()
};
assert!(args.validate().is_ok());
}
#[test]
fn dpi_zero_is_rejected() {
let args = Args {
dpi: Some(0),
..default_args()
};
let err = args.validate().unwrap_err();
assert!(matches!(err, CliError::InvalidArgs(_)));
}
#[test]
fn dpi_above_max_is_rejected() {
let args = Args {
dpi: Some(9601),
..default_args()
};
let err = args.validate().unwrap_err();
assert!(matches!(err, CliError::InvalidArgs(_)));
}
#[test]
fn segment_flag_passes_validation() {
let args = Args {
segment: true,
..default_args()
};
assert!(args.validate().is_ok());
}
#[test]
fn clap_parse_defaults() {
let args = Args::parse_from(["jbig2", "input.png"]);
assert_eq!(args.basename, "output");
assert!(!args.duplicate_line_removal);
assert!(!args.pdf);
assert!(!args.symbol_mode);
assert_eq!(args.threshold, 0.92);
assert_eq!(args.weight, 0.5);
assert_eq!(args.bw_threshold, None);
assert!(!args.global);
assert!(!args.refine);
assert!(args.output_threshold.is_none());
assert!(!args.up2);
assert!(!args.up4);
assert!(!args.segment);
assert!(!args.jpeg_output);
assert!(!args.auto_thresh);
assert!(!args.no_hash);
assert!(args.dpi.is_none());
assert!(!args.verbose);
assert_eq!(args.files, vec!["input.png"]);
}
#[test]
fn clap_parse_all_flags() {
let args = Args::parse_from([
"jbig2",
"-b",
"out",
"-d",
"-p",
"-s",
"-t",
"0.85",
"-w",
"0.3",
"-T",
"180",
"-G",
"-O",
"debug.png",
"-2",
"-a",
"--no-hash",
"-D",
"300",
"-v",
"a.png",
"b.png",
]);
assert_eq!(args.basename, "out");
assert!(args.duplicate_line_removal);
assert!(args.pdf);
assert!(args.symbol_mode);
assert_eq!(args.threshold, 0.85);
assert_eq!(args.weight, 0.3);
assert_eq!(args.bw_threshold, Some(180));
assert!(args.global);
assert_eq!(args.output_threshold.as_deref(), Some("debug.png"));
assert!(args.up2);
assert!(!args.up4);
assert!(args.auto_thresh);
assert!(args.no_hash);
assert_eq!(args.dpi, Some(300));
assert!(args.verbose);
assert_eq!(args.files, vec!["a.png", "b.png"]);
}
}