use crate::op::{
AlphaOp, CameraSpec, ConvertPlan, Dither, GltfFormatChoice, LightSpec, Mesh3DOptions,
Mesh3DRenderMode, Op, PageAtom, PageSelector, PrintfTemplate, ProjectionMode, ResizeMode,
StlFormatChoice,
};
use oxideav_core::Error;
const KNOWN_FLAG_NAMES: &[&str] = &[
"aa",
"alpha",
"auto-gamma",
"background",
"bg",
"blur",
"brightness-contrast",
"camera",
"colorize",
"colors",
"colorspace",
"contrast",
"crop",
"define",
"density",
"dither",
"edge",
"equalize",
"extent",
"flip",
"flop",
"format",
"fov",
"fuzz",
"gamma",
"gltf-format",
"json",
"level",
"light",
"modulate",
"monochrome",
"negate",
"normalize",
"ping",
"posterize",
"probe",
"projection",
"quality",
"render",
"resize",
"roll",
"rotate",
"sepia",
"sharpen",
"solarize",
"stl-format",
"strip",
"thumbnail",
"threshold",
"trim",
"unsharp",
"vignette",
"watch",
];
pub fn parse(args: &[String]) -> Result<ConvertPlan, Error> {
if args.is_empty() {
return Err(Error::invalid(
"convert: no input file given (usage: convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT)",
));
}
let mut ops: Vec<Op> = Vec::new();
let mut positionals: Vec<String> = Vec::new();
let mut pending_dither = Dither::None;
let mut ping = false;
let mut probe = false;
let mut probe_json = false;
let mut probe_watch = false;
let mut mesh3d_options = Mesh3DOptions::default();
let mut current_bg: [u8; 4] = [255, 255, 255, 255];
let mut current_fuzz: u8 = 0;
let mut i = 0;
while i < args.len() {
let flag = &args[i];
let val = |k: usize| -> Result<&str, Error> {
args.get(k)
.map(|s| s.as_str())
.ok_or_else(|| Error::invalid(format!("convert: {flag}: missing value")))
};
if !flag.starts_with('-') {
positionals.push(flag.clone());
i += 1;
continue;
}
match flag.as_str() {
"-resize" => {
let v = val(i + 1)?;
let (mode, core) = ResizeMode::split_suffix(v);
let (w, h) = parse_resize_geometry(core, mode)
.map_err(|e| Error::invalid(format!("convert: -resize: {e}")))?;
ops.push(Op::Resize {
width: w,
height: h,
mode,
});
i += 2;
}
"-thumbnail" => {
let v = val(i + 1)?;
let (mode, core) = ResizeMode::split_suffix(v);
let (w, h) = parse_resize_geometry(core, mode)
.map_err(|e| Error::invalid(format!("convert: -thumbnail: {e}")))?;
ops.push(Op::Thumbnail {
width: w,
height: h,
mode,
});
i += 2;
}
"-define" => {
let v = val(i + 1)?;
let (key, value) = match v.split_once('=') {
Some((k, val)) => (k.to_string(), Some(val.to_string())),
None => (v.to_string(), None),
};
if key.is_empty() {
return Err(Error::invalid(format!(
"convert: -define: '{v}' has an empty KEY (expected KEY[=VALUE])"
)));
}
ops.push(Op::Define { key, value });
i += 2;
}
"-blur" => {
let v = val(i + 1)?;
let (radius, sigma) =
parse_blur(v).map_err(|e| Error::invalid(format!("convert: -blur: {e}")))?;
ops.push(Op::Blur { radius, sigma });
i += 2;
}
"-edge" => {
let v = val(i + 1)?;
let r: u32 = v.parse().map_err(|_| {
Error::invalid(format!(
"convert: -edge: '{v}' is not a non-negative integer"
))
})?;
ops.push(Op::Edge { radius: r });
i += 2;
}
"-colors" => {
let v = val(i + 1)?;
let n: u32 = v.parse().map_err(|_| {
Error::invalid(format!(
"convert: -colors: '{v}' is not a non-negative integer"
))
})?;
if !(2..=256).contains(&n) {
return Err(Error::invalid(format!(
"convert: -colors {n} out of range (2..=256)"
)));
}
ops.push(Op::Colors {
count: n,
dither: pending_dither,
});
i += 2;
}
"-dither" => {
let v = val(i + 1)?;
pending_dither = Dither::parse(v).map_err(Error::invalid)?;
i += 2;
}
"-monochrome" => {
ops.push(Op::Colorspace("gray".to_string()));
ops.push(Op::Colors {
count: 2,
dither: Dither::FloydSteinberg,
});
i += 1;
}
"-format" => {
let v = val(i + 1)?;
ops.push(Op::Format(v.to_string()));
i += 2;
}
"-quality" => {
let v = val(i + 1)?;
let q: u32 = v.parse().map_err(|_| {
Error::invalid(format!(
"convert: -quality: '{v}' is not a non-negative integer"
))
})?;
if q > 100 {
return Err(Error::invalid(format!(
"convert: -quality: {q} out of range (must be in 0..=100; common values: 75 default, 85 web, 95 archival)"
)));
}
ops.push(Op::Quality(q));
i += 2;
}
"-strip" => {
ops.push(Op::Strip);
i += 1;
}
"-ping" => {
ping = true;
i += 1;
}
"--probe" => {
probe = true;
i += 1;
}
"--json" => {
probe_json = true;
i += 1;
}
"--watch" => {
probe_watch = true;
i += 1;
}
"-density" => {
let v = val(i + 1)?;
let n: u32 = v.parse().map_err(|_| {
Error::invalid(format!(
"convert: -density: '{v}' is not a non-negative integer"
))
})?;
if n == 0 {
return Err(Error::invalid("convert: -density must be > 0"));
}
ops.push(Op::Density(n));
i += 2;
}
"-background" => {
let v = val(i + 1)?;
let rgba = parse_color(v).map_err(Error::invalid)?;
current_bg = rgba;
ops.push(Op::Background(rgba));
i += 2;
}
"-alpha" => {
let v = val(i + 1)?;
let a = AlphaOp::parse(v).map_err(Error::invalid)?;
ops.push(Op::Alpha(a));
i += 2;
}
"-rotate" => {
let v = val(i + 1)?;
let n: i32 = v.parse().map_err(|_| {
Error::invalid(format!("convert: -rotate: '{v}' is not an integer"))
})?;
if !matches!(n, 90 | 180 | 270 | -90 | -180 | -270) {
return Err(Error::invalid(format!(
"convert: -rotate: only multiples of 90 supported (got {n})"
)));
}
ops.push(Op::Rotate { degrees: n });
i += 2;
}
"-flip" => {
ops.push(Op::Flip);
i += 1;
}
"-flop" => {
ops.push(Op::Flop);
i += 1;
}
"-negate" => {
ops.push(Op::Negate);
i += 1;
}
"-crop" => {
let v = val(i + 1)?;
let (w, h, x, y) =
parse_crop(v).map_err(|e| Error::invalid(format!("convert: -crop: {e}")))?;
ops.push(Op::Crop { x, y, w, h });
i += 2;
}
"-extent" => {
let v = val(i + 1)?;
let (w, h, x, y) = parse_extent(v)
.map_err(|e| Error::invalid(format!("convert: -extent: {e}")))?;
ops.push(Op::Extent {
width: w,
height: h,
x,
y,
bg: current_bg,
});
i += 2;
}
"-fuzz" => {
let v = val(i + 1)?;
current_fuzz = parse_threshold_pct(v)
.map_err(|e| Error::invalid(format!("convert: -fuzz: {e}")))?;
i += 2;
}
"-trim" => {
ops.push(Op::Trim { fuzz: current_fuzz });
i += 1;
}
"-roll" => {
let v = val(i + 1)?;
let (dx, dy) =
parse_roll(v).map_err(|e| Error::invalid(format!("convert: -roll: {e}")))?;
ops.push(Op::Roll { dx, dy });
i += 2;
}
"-sharpen" => {
let v = val(i + 1)?;
let (radius, sigma) =
parse_blur(v).map_err(|e| Error::invalid(format!("convert: -sharpen: {e}")))?;
ops.push(Op::Sharpen { radius, sigma });
i += 2;
}
"-unsharp" => {
let v = val(i + 1)?;
let (radius, sigma, amount, threshold) = parse_unsharp(v)
.map_err(|e| Error::invalid(format!("convert: -unsharp: {e}")))?;
ops.push(Op::Unsharp {
radius,
sigma,
amount,
threshold,
});
i += 2;
}
"-gamma" => {
let v = val(i + 1)?;
let g: f32 = v.parse().map_err(|_| {
Error::invalid(format!("convert: -gamma: '{v}' is not a finite float"))
})?;
if !g.is_finite() || g <= 0.0 {
return Err(Error::invalid(format!(
"convert: -gamma: value must be > 0 (got {g})"
)));
}
ops.push(Op::Gamma { value: g });
i += 2;
}
"-brightness-contrast" => {
let v = val(i + 1)?;
let (b, c) = parse_brightness_contrast(v)
.map_err(|e| Error::invalid(format!("convert: -brightness-contrast: {e}")))?;
ops.push(Op::BrightnessContrast {
brightness: b,
contrast: c,
});
i += 2;
}
"-contrast" => {
ops.push(Op::Contrast { delta: 1 });
i += 1;
}
"-sepia" => {
let v = val(i + 1)?;
let t = parse_percent_or_unit(v)
.map_err(|e| Error::invalid(format!("convert: -sepia: {e}")))?;
ops.push(Op::Sepia { threshold: t });
i += 2;
}
"-modulate" => {
let v = val(i + 1)?;
let (b, s, h) = parse_modulate(v)
.map_err(|e| Error::invalid(format!("convert: -modulate: {e}")))?;
ops.push(Op::Modulate {
brightness: b,
saturation: s,
hue: h,
});
i += 2;
}
"-level" => {
let v = val(i + 1)?;
let (b, g, w) =
parse_level(v).map_err(|e| Error::invalid(format!("convert: -level: {e}")))?;
ops.push(Op::Level {
black: b,
gamma: g,
white: w,
});
i += 2;
}
"-normalize" => {
ops.push(Op::Normalize);
i += 1;
}
"-threshold" => {
let v = val(i + 1)?;
let n = parse_threshold_pct(v)
.map_err(|e| Error::invalid(format!("convert: -threshold: {e}")))?;
ops.push(Op::Threshold { value: n });
i += 2;
}
"-posterize" => {
let v = val(i + 1)?;
let n: u32 = v.parse().map_err(|_| {
Error::invalid(format!(
"convert: -posterize: '{v}' is not a non-negative integer"
))
})?;
if n < 2 {
return Err(Error::invalid(format!(
"convert: -posterize: levels must be >= 2 (got {n})"
)));
}
ops.push(Op::Posterize { levels: n });
i += 2;
}
"-solarize" => {
let v = val(i + 1)?;
let n = parse_threshold_pct(v)
.map_err(|e| Error::invalid(format!("convert: -solarize: {e}")))?;
ops.push(Op::Solarize { value: n });
i += 2;
}
"-vignette" => {
let v = val(i + 1)?;
let (radius, sigma, x, y) = parse_vignette(v)
.map_err(|e| Error::invalid(format!("convert: -vignette: {e}")))?;
ops.push(Op::Vignette {
radius,
sigma,
x,
y,
});
i += 2;
}
"-colorize" => {
let v = val(i + 1)?;
let (color, amount) = parse_colorize(v)
.map_err(|e| Error::invalid(format!("convert: -colorize: {e}")))?;
ops.push(Op::Colorize { color, amount });
i += 2;
}
"-equalize" => {
ops.push(Op::Equalize);
i += 1;
}
"-auto-gamma" => {
ops.push(Op::AutoGamma);
i += 1;
}
"-colorspace" => {
let v = val(i + 1)?;
let cs = v.trim().to_string();
let lower = cs.to_ascii_lowercase();
if !matches!(lower.as_str(), "gray" | "grey" | "rgb" | "srgb") {
return Err(Error::unsupported(format!(
"convert: -colorspace '{cs}' is not yet wired (round-1 supports gray/grey/rgb/srgb)"
)));
}
ops.push(Op::Colorspace(cs));
i += 2;
}
"-stl-format" => {
let v = val(i + 1)?;
let choice = StlFormatChoice::parse(v).map_err(Error::invalid)?;
mesh3d_options.stl_format = Some(choice);
i += 2;
}
"-gltf-format" => {
let v = val(i + 1)?;
let choice = GltfFormatChoice::parse(v).map_err(Error::invalid)?;
mesh3d_options.gltf_format = Some(choice);
i += 2;
}
"-render" => {
let v = val(i + 1)?;
let mode = Mesh3DRenderMode::parse(v).map_err(Error::invalid)?;
mesh3d_options.render_mode = Some(mode);
i += 2;
}
"-light" => {
let v = val(i + 1)?;
let spec = LightSpec::parse(v).map_err(Error::invalid)?;
mesh3d_options.light = Some(spec);
i += 2;
}
"-camera" => {
let v = val(i + 1)?;
let spec = CameraSpec::parse(v).map_err(Error::invalid)?;
mesh3d_options.camera = Some(spec);
i += 2;
}
"-projection" => {
let v = val(i + 1)?;
let mode = ProjectionMode::parse(v).map_err(Error::invalid)?;
mesh3d_options.projection = Some(mode);
i += 2;
}
"-fov" => {
let v = val(i + 1)?;
let fov: f32 = v
.parse()
.map_err(|_| Error::invalid(format!("convert: -fov: '{v}' is not a number")))?;
if !fov.is_finite() || fov <= 0.0 || fov >= 180.0 {
return Err(Error::invalid(format!(
"convert: -fov: {fov} must be in (0, 180) degrees"
)));
}
mesh3d_options.fov_deg = Some(fov);
i += 2;
}
"-bg" => {
let v = val(i + 1)?;
let colour = parse_color(v).map_err(Error::invalid)?;
mesh3d_options.bg = Some(colour);
i += 2;
}
"-aa" => {
let v = val(i + 1)?;
let factor: u32 = v.parse().map_err(|_| {
Error::invalid(format!("convert: -aa: '{v}' is not a positive integer"))
})?;
if !(1..=8).contains(&factor) {
return Err(Error::invalid(format!(
"convert: -aa: factor {factor} out of range (must be in 1..=8)"
)));
}
mesh3d_options.aa = Some(factor);
i += 2;
}
other => {
let (lead, bare) = if let Some(rest) = other.strip_prefix("--") {
("--", rest)
} else {
("-", other.strip_prefix('-').unwrap_or(other))
};
let hint = crate::suggest::closest_match(bare, KNOWN_FLAG_NAMES)
.map(|s| format!(" (did you mean '{lead}{s}'?)"))
.unwrap_or_default();
return Err(Error::invalid(format!(
"convert: unknown flag '{other}'{hint}"
)));
}
}
}
if positionals.is_empty() {
return Err(Error::invalid(
"convert: no input file given (usage: convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT)",
));
}
if probe && positionals.len() >= 2 {
return Err(Error::invalid(format!(
"convert: --probe cannot be combined with an output file ('{}'); use either --probe OR an output file, not both",
positionals[1]
)));
}
if positionals.len() < 2 && !ping && !probe {
return Err(Error::invalid(
"convert: no output file given (usage: convert [-op VALUE]... INPUT [-op VALUE]... OUTPUT)",
));
}
if positionals.len() > 2 {
return Err(Error::unsupported(format!(
"convert: {} positional arguments given but multi-input is not yet supported (round-2 follow-up); pass exactly INPUT OUTPUT",
positionals.len()
)));
}
if probe_json && !probe {
return Err(Error::invalid(
"convert: --json requires --probe (today --json only formats the probe summary)",
));
}
if probe_watch && !probe {
return Err(Error::invalid(
"convert: --watch requires --probe (today --watch only re-runs the probe on input mtime change)",
));
}
let (raw_input, input_pages) = split_input_selector(&positionals[0])?;
let input = translate_input_shorthand(raw_input);
let output = positionals.get(1).cloned().unwrap_or_default();
let output_template = if output.is_empty() {
None
} else {
parse_printf_template(&output)?
};
Ok(ConvertPlan {
input,
input_pages,
ops,
output,
output_template,
ping,
probe,
probe_json,
probe_watch,
mesh3d_options,
})
}
pub(crate) fn split_input_selector(s: &str) -> Result<(&str, Option<PageSelector>), Error> {
if !s.ends_with(']') {
return Ok((s, None));
}
let open = match s.rfind('[') {
Some(i) => i,
None => {
return Err(Error::invalid(format!(
"convert: input '{s}' has a closing `]` with no matching `[`"
)));
}
};
let body = &s[open + 1..s.len() - 1];
if body.is_empty() {
return Err(Error::invalid(format!(
"convert: input '{s}' has an empty `[]` page selector"
)));
}
let sel = parse_page_selector_body(s, body)?;
Ok((&s[..open], Some(sel)))
}
fn parse_page_selector_body(input: &str, body: &str) -> Result<PageSelector, Error> {
let pieces: Vec<&str> = body.split(',').collect();
let mut atoms: Vec<PageAtom> = Vec::with_capacity(pieces.len());
for piece in &pieces {
let trimmed = piece.trim();
if trimmed.is_empty() {
return Err(Error::invalid(format!(
"convert: input '{input}': page selector '{body}' has an empty atom (between commas)"
)));
}
atoms.push(parse_page_atom(input, trimmed)?);
}
Ok(if atoms.len() == 1 {
match atoms.into_iter().next().unwrap() {
PageAtom::Single(n) => PageSelector::Single(n),
PageAtom::Range(a, b) => PageSelector::Range(a, b),
}
} else {
PageSelector::List(atoms)
})
}
fn parse_page_atom(input: &str, atom: &str) -> Result<PageAtom, Error> {
let (first_sign, rest) = if let Some(rest) = atom.strip_prefix('-') {
("-", rest)
} else {
("", atom)
};
if rest.is_empty() {
return Err(Error::invalid(format!(
"convert: input '{input}': page selector atom '{atom}' is just a sign with no digits"
)));
}
match rest.find('-') {
None => {
let n: isize = format!("{first_sign}{rest}").parse().map_err(|_| {
Error::invalid(format!(
"convert: input '{input}': page selector atom '{atom}' is not an integer page index"
))
})?;
Ok(PageAtom::Single(n))
}
Some(sep) => {
let left = format!("{first_sign}{}", &rest[..sep]);
let right_raw = &rest[sep + 1..];
if left.is_empty() || left == "-" {
return Err(Error::invalid(format!(
"convert: input '{input}': page selector atom '{atom}' has no left endpoint"
)));
}
if right_raw.is_empty() {
return Err(Error::invalid(format!(
"convert: input '{input}': page selector atom '{atom}' has no right endpoint"
)));
}
let a: isize = left.parse().map_err(|_| {
Error::invalid(format!(
"convert: input '{input}': page selector atom '{atom}': left endpoint '{left}' is not an integer"
))
})?;
let b: isize = right_raw.parse().map_err(|_| {
Error::invalid(format!(
"convert: input '{input}': page selector atom '{atom}': right endpoint '{right_raw}' is not an integer (expected `N-M`, `-N-M`, or `N--M`)"
))
})?;
Ok(PageAtom::Range(a, b))
}
}
}
pub(crate) fn parse_printf_template(s: &str) -> Result<Option<PrintfTemplate>, Error> {
let bytes = s.as_bytes();
let mut i = 0usize;
let mut found: Option<(usize, u8, usize)> = None; while i < bytes.len() {
if bytes[i] != b'%' {
i += 1;
continue;
}
let start = i;
i += 1;
let mut width: u8 = 0;
while i < bytes.len() && bytes[i].is_ascii_digit() {
let d = (bytes[i] - b'0') as u32;
let next = (width as u32) * 10 + d;
if next > 20 {
return Err(Error::invalid(format!(
"convert: output template '{s}' has an unreasonable width specifier"
)));
}
width = next as u8;
i += 1;
}
if i >= bytes.len() {
return Err(Error::invalid(format!(
"convert: output template '{s}' has a `%` with no format specifier"
)));
}
match bytes[i] {
b'd' => {
if found.is_some() {
return Err(Error::invalid(format!(
"convert: output template '{s}' has more than one `%d` token (expected exactly one)"
)));
}
found = Some((start, width, i + 1));
i += 1;
}
other => {
return Err(Error::invalid(format!(
"convert: output template '{s}' uses unsupported format specifier '%{}' (only `%d`, `%03d`, etc. are accepted)",
other as char
)));
}
}
}
Ok(found.map(|(start, width, end)| PrintfTemplate {
prefix: s[..start].to_string(),
width,
suffix: s[end..].to_string(),
}))
}
fn parse_color(s: &str) -> Result<[u8; 4], String> {
let s = s.trim();
if let Some(hex) = s.strip_prefix('#') {
return parse_hex_color(hex);
}
match s.to_ascii_lowercase().as_str() {
"transparent" | "none" => Ok([0, 0, 0, 0]),
"black" => Ok([0, 0, 0, 255]),
"white" => Ok([255, 255, 255, 255]),
"red" => Ok([255, 0, 0, 255]),
"green" => Ok([0, 128, 0, 255]),
"lime" => Ok([0, 255, 0, 255]),
"blue" => Ok([0, 0, 255, 255]),
"yellow" => Ok([255, 255, 0, 255]),
"cyan" | "aqua" => Ok([0, 255, 255, 255]),
"magenta" | "fuchsia" => Ok([255, 0, 255, 255]),
"gray" | "grey" => Ok([128, 128, 128, 255]),
"silver" => Ok([192, 192, 192, 255]),
"maroon" => Ok([128, 0, 0, 255]),
"olive" => Ok([128, 128, 0, 255]),
"purple" => Ok([128, 0, 128, 255]),
"teal" => Ok([0, 128, 128, 255]),
"navy" => Ok([0, 0, 128, 255]),
"orange" => Ok([255, 165, 0, 255]),
other => Err(format!(
"convert: unknown colour '{other}' (try a `#hex` value or one of: black/white/red/green/blue/yellow/cyan/magenta/gray/transparent/…)"
)),
}
}
fn parse_hex_color(hex: &str) -> Result<[u8; 4], String> {
fn hex_digit(c: u8) -> Result<u8, String> {
match c {
b'0'..=b'9' => Ok(c - b'0'),
b'a'..=b'f' => Ok(c - b'a' + 10),
b'A'..=b'F' => Ok(c - b'A' + 10),
_ => Err(format!("'{}' is not a hex digit", c as char)),
}
}
let h = hex.as_bytes();
let pair = |a: u8, b: u8| -> Result<u8, String> { Ok(hex_digit(a)? * 16 + hex_digit(b)?) };
let dup = |a: u8| -> Result<u8, String> {
let d = hex_digit(a)?;
Ok(d * 16 + d)
};
match h.len() {
3 => Ok([dup(h[0])?, dup(h[1])?, dup(h[2])?, 255]),
4 => Ok([dup(h[0])?, dup(h[1])?, dup(h[2])?, dup(h[3])?]),
6 => Ok([pair(h[0], h[1])?, pair(h[2], h[3])?, pair(h[4], h[5])?, 255]),
8 => Ok([
pair(h[0], h[1])?,
pair(h[2], h[3])?,
pair(h[4], h[5])?,
pair(h[6], h[7])?,
]),
_ => Err(format!(
"'#{hex}' is not a valid hex colour (expected 3/4/6/8 hex digits)"
)),
}
}
#[cfg(feature = "generator")]
fn translate_input_shorthand(input: &str) -> String {
oxideav_generator::shorthand::translate(input)
}
#[cfg(not(feature = "generator"))]
fn translate_input_shorthand(input: &str) -> String {
input.to_string()
}
fn parse_resize_geometry(s: &str, mode: ResizeMode) -> Result<(u32, u32), String> {
if matches!(mode, ResizeMode::Percent) {
let s = s.trim();
let (w_str, h_str) = match s.split_once(['x', 'X']) {
None => (s, s), Some((w, h)) => {
let w = if w.is_empty() { h } else { w };
let h = if h.is_empty() { w } else { h };
(w, h)
}
};
let w: u32 = w_str
.parse()
.map_err(|_| format!("'{w_str}' is not a non-negative integer percent"))?;
let h: u32 = h_str
.parse()
.map_err(|_| format!("'{h_str}' is not a non-negative integer percent"))?;
if w == 0 || h == 0 {
return Err("percent values must both be > 0".into());
}
return Ok((w, h));
}
parse_wxh(s)
}
fn parse_wxh(s: &str) -> Result<(u32, u32), String> {
let (w, h) = s
.split_once(['x', 'X'])
.ok_or_else(|| format!("'{s}' is not in WxH form"))?;
let w: u32 = w
.parse()
.map_err(|_| format!("'{w}' is not a non-negative integer"))?;
let h: u32 = h
.parse()
.map_err(|_| format!("'{h}' is not a non-negative integer"))?;
if w == 0 || h == 0 {
return Err("width and height must both be positive".into());
}
Ok((w, h))
}
fn parse_crop(s: &str) -> Result<(u32, u32, u32, u32), String> {
for mark in ['%', '!', '^', '<', '>', '@'] {
if s.contains(mark) {
return Err(format!(
"'{s}' uses IM modifier '{mark}' (round-1 supports plain WxH+X+Y only)"
));
}
}
let mut parts = s.split('+');
let wh = parts
.next()
.ok_or_else(|| format!("'{s}' is missing the WxH component"))?;
let x_str = parts
.next()
.ok_or_else(|| format!("'{s}' is missing the +X offset (expected WxH+X+Y)"))?;
let y_str = parts
.next()
.ok_or_else(|| format!("'{s}' is missing the +Y offset (expected WxH+X+Y)"))?;
if parts.next().is_some() {
return Err(format!("'{s}' has more than two `+` separators"));
}
let (w, h) = parse_wxh(wh)?;
let x: u32 = x_str
.parse()
.map_err(|_| format!("'{x_str}' is not a non-negative integer X offset"))?;
let y: u32 = y_str
.parse()
.map_err(|_| format!("'{y_str}' is not a non-negative integer Y offset"))?;
Ok((w, h, x, y))
}
fn parse_extent(s: &str) -> Result<(u32, u32, i32, i32), String> {
for mark in ['%', '!', '^', '<', '>', '@'] {
if s.contains(mark) {
return Err(format!(
"'{s}' uses IM modifier '{mark}' (-extent currently supports plain WxH[±X±Y] only)"
));
}
}
let head_end = s
.char_indices()
.skip(1)
.find(|(_, c)| *c == '+' || *c == '-')
.map(|(i, _)| i)
.unwrap_or(s.len());
let head = &s[..head_end];
let tail = &s[head_end..];
let (w, h) = parse_wxh(head)?;
let (x, y) = parse_extent_offsets(tail)?;
Ok((w, h, x, y))
}
fn parse_extent_offsets(s: &str) -> Result<(i32, i32), String> {
if s.is_empty() {
return Ok((0, 0));
}
let bytes = s.as_bytes();
if bytes[0] != b'+' && bytes[0] != b'-' {
return Err(format!(
"'{s}' offset section must start with `+` or `-` (expected WxH+X+Y, WxH-X-Y, or mixed)"
));
}
let mut split = None;
for (i, b) in bytes.iter().enumerate().skip(1) {
if *b == b'+' || *b == b'-' {
split = Some(i);
break;
}
}
let split = split
.ok_or_else(|| format!("'{s}' has a single offset; expected both X and Y (WxH±X±Y)"))?;
let x_str = &s[..split];
let y_str = &s[split..];
if y_str[1..].contains('+') || y_str[1..].contains('-') {
return Err(format!("'{s}' has more than two signed offsets"));
}
let x: i32 = x_str
.parse()
.map_err(|_| format!("'{x_str}' is not a signed integer X offset"))?;
let y: i32 = y_str
.parse()
.map_err(|_| format!("'{y_str}' is not a signed integer Y offset"))?;
Ok((x, y))
}
fn parse_roll(s: &str) -> Result<(i32, i32), String> {
if s.is_empty() {
return Err("empty offset (expected ±X±Y, e.g. +5+10)".to_string());
}
parse_extent_offsets(s)
}
fn parse_blur(s: &str) -> Result<(u32, f32), String> {
let (radius_str, sigma_str) = match s.split_once(['x', 'X']) {
Some((r, s)) => (r, Some(s)),
None => (s, None),
};
let radius: u32 = radius_str
.parse()
.map_err(|_| format!("'{radius_str}' is not a non-negative integer"))?;
let sigma: f32 = match sigma_str {
Some(s) => s
.parse()
.map_err(|_| format!("'{s}' is not a non-negative float"))?,
None => (radius as f32) / 2.0,
};
Ok((radius, sigma))
}
fn parse_unsharp(s: &str) -> Result<(u32, f32, f32, u8), String> {
let mut parts = s.splitn(3, '+');
let head = parts.next().unwrap_or(s);
let amount_str = parts.next();
let threshold_str = parts.next();
let (radius, sigma) = parse_blur(head)?;
let amount: f32 = match amount_str {
Some(a) => a
.parse()
.map_err(|_| format!("'{a}' is not a finite float"))?,
None => 1.0,
};
let threshold: u8 = match threshold_str {
Some(t) => parse_threshold_pct(t)?,
None => 0,
};
Ok((radius, sigma, amount, threshold))
}
fn parse_brightness_contrast(s: &str) -> Result<(f32, f32), String> {
let (bs, cs) = match s.split_once(['x', 'X', ',']) {
Some((a, b)) => (a, Some(b)),
None => (s, None),
};
let b: f32 = bs
.parse()
.map_err(|_| format!("'{bs}' is not a finite float"))?;
let c: f32 = match cs {
Some(s) if !s.is_empty() => s
.parse()
.map_err(|_| format!("'{s}' is not a finite float"))?,
_ => 0.0,
};
if !(-100.0..=100.0).contains(&b) {
return Err(format!("brightness {b} out of range (-100..=100)"));
}
if !(-100.0..=100.0).contains(&c) {
return Err(format!("contrast {c} out of range (-100..=100)"));
}
Ok((b, c))
}
fn parse_percent_or_unit(s: &str) -> Result<f32, String> {
let s = s.trim();
if let Some(p) = s.strip_suffix('%') {
let v: f32 = p
.parse()
.map_err(|_| format!("'{p}' is not a finite float"))?;
if !(0.0..=100.0).contains(&v) {
return Err(format!("'{s}' is out of range (0%..=100%)"));
}
return Ok(v / 100.0);
}
let v: f32 = s
.parse()
.map_err(|_| format!("'{s}' is not a finite float"))?;
if !(0.0..=1.0).contains(&v) {
return Err(format!("'{s}' is out of range (0.0..=1.0)"));
}
Ok(v)
}
fn parse_modulate(s: &str) -> Result<(f32, f32, f32), String> {
let mut parts = s.split(',').map(|p| p.trim());
let b = parts
.next()
.ok_or_else(|| format!("'{s}' is missing the brightness value"))?;
let s_field = parts.next();
let h_field = parts.next();
if parts.next().is_some() {
return Err(format!(
"'{s}' has more than 3 components (expected B[,S[,H]])"
));
}
let bv: f32 = b
.parse()
.map_err(|_| format!("'{b}' is not a finite float"))?;
let sv: f32 = match s_field {
Some(t) if !t.is_empty() => t
.parse()
.map_err(|_| format!("'{t}' is not a finite float"))?,
_ => 100.0,
};
let hv: f32 = match h_field {
Some(t) if !t.is_empty() => t
.parse()
.map_err(|_| format!("'{t}' is not a finite float"))?,
_ => 100.0,
};
Ok((bv, sv, hv))
}
fn parse_level(s: &str) -> Result<(u8, f32, u8), String> {
let parts: Vec<&str> = s.split(['/', ',']).map(|p| p.trim()).collect();
if parts.is_empty() || parts.len() > 3 {
return Err(format!("'{s}' is not in B[/G[/W]] form (1..=3 components)"));
}
let black = parse_byte_or_percent(parts[0])?;
let gamma: f32 = if parts.len() >= 2 && !parts[1].is_empty() {
let g: f32 = parts[1]
.parse()
.map_err(|_| format!("'{}' is not a finite float", parts[1]))?;
if !g.is_finite() || g <= 0.0 {
return Err(format!("gamma {g} must be > 0"));
}
g
} else {
1.0
};
let white = if parts.len() == 3 && !parts[2].is_empty() {
parse_byte_or_percent(parts[2])?
} else {
255
};
if black > white {
return Err(format!(
"black point ({black}) must be <= white point ({white})"
));
}
Ok((black, gamma, white))
}
fn parse_byte_or_percent(s: &str) -> Result<u8, String> {
if let Some(p) = s.strip_suffix('%') {
let v: f32 = p
.parse()
.map_err(|_| format!("'{p}' is not a finite float"))?;
if !(0.0..=100.0).contains(&v) {
return Err(format!("'{s}' is out of range (0%..=100%)"));
}
return Ok((v * 2.55).round().clamp(0.0, 255.0) as u8);
}
let n: u32 = s
.parse()
.map_err(|_| format!("'{s}' is not a non-negative integer"))?;
if n > 255 {
return Err(format!("'{s}' is out of range (0..=255)"));
}
Ok(n as u8)
}
fn parse_threshold_pct(s: &str) -> Result<u8, String> {
parse_byte_or_percent(s.trim())
}
fn parse_vignette(s: &str) -> Result<(f32, f32, f32, f32), String> {
let mut parts = s.split('+');
let r_str = parts
.next()
.ok_or_else(|| format!("'{s}' is missing the radius component"))?;
let radius: f32 = r_str
.parse()
.map_err(|_| format!("'{r_str}' is not a finite float radius"))?;
if !radius.is_finite() || radius < 0.0 {
return Err(format!("radius {radius} must be >= 0"));
}
let s_part = parts.next();
let sigma: f32 = match s_part {
Some(t) if !t.is_empty() => t
.parse()
.map_err(|_| format!("'{t}' is not a finite float sigma"))?,
_ => radius / 2.0,
};
if !sigma.is_finite() || sigma < 0.0 {
return Err(format!("sigma {sigma} must be >= 0"));
}
let x: f32 = match parts.next() {
Some(t) if !t.is_empty() => t
.parse()
.map_err(|_| format!("'{t}' is not a finite float x"))?,
_ => 0.5,
};
let y: f32 = match parts.next() {
Some(t) if !t.is_empty() => t
.parse()
.map_err(|_| format!("'{t}' is not a finite float y"))?,
_ => 0.5,
};
if parts.next().is_some() {
return Err(format!("'{s}' has more than 4 components"));
}
if !x.is_finite() || !y.is_finite() {
return Err(format!("x/y must be finite (got x={x}, y={y})"));
}
Ok((radius, sigma, x, y))
}
fn parse_colorize(s: &str) -> Result<([u8; 4], f32), String> {
let s = s.trim();
let (color_part, amount_part) = match s.split_once('/') {
Some((c, a)) => (c, a),
None => ("", s),
};
let color = if color_part.is_empty() {
[255, 255, 255, 255]
} else if color_part.contains('x') || color_part.contains('X') {
let mut parts = color_part.split(['x', 'X']);
let r_str = parts
.next()
.ok_or_else(|| format!("'{color_part}' is missing the R component"))?;
let r: u32 = r_str
.parse()
.map_err(|_| format!("'{r_str}' is not a non-negative integer"))?;
if r > 255 {
return Err(format!("R component {r} out of range (0..=255)"));
}
let g: u32 = match parts.next() {
Some(t) if !t.is_empty() => t
.parse()
.map_err(|_| format!("'{t}' is not a non-negative integer"))?,
_ => r,
};
if g > 255 {
return Err(format!("G component {g} out of range (0..=255)"));
}
let b: u32 = match parts.next() {
Some(t) if !t.is_empty() => t
.parse()
.map_err(|_| format!("'{t}' is not a non-negative integer"))?,
_ => g,
};
if b > 255 {
return Err(format!("B component {b} out of range (0..=255)"));
}
if parts.next().is_some() {
return Err(format!(
"'{color_part}' has more than 3 components (expected R[xG[xB]])"
));
}
[r as u8, g as u8, b as u8, 255]
} else {
parse_color(color_part)?
};
let amount = parse_percent_or_unit(amount_part)?;
Ok((color, amount))
}
#[cfg(test)]
mod tests {
use super::*;
fn to_vec(s: &[&str]) -> Vec<String> {
s.iter().map(|x| x.to_string()).collect()
}
#[test]
fn minimal_input_output() {
let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
assert_eq!(p.input, "in.png");
assert_eq!(p.output, "out.jpg");
assert!(p.ops.is_empty());
}
#[test]
fn resize_bilinear_basic() {
let p = parse(&to_vec(&["a.png", "-resize", "800x600", "b.jpg"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Resize {
width: 800,
height: 600,
mode: ResizeMode::Default,
}]
);
}
#[test]
fn resize_bang_flag() {
let p = parse(&to_vec(&["a.png", "-resize", "64x32!", "b.jpg"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Resize {
width: 64,
height: 32,
mode: ResizeMode::Force,
}]
);
}
#[test]
fn resize_fill_caret_suffix() {
let p = parse(&to_vec(&["a.png", "-resize", "200x100^", "b.jpg"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Resize {
width: 200,
height: 100,
mode: ResizeMode::Fill,
}]
);
}
#[test]
fn resize_shrink_only_suffix() {
let p = parse(&to_vec(&["a.png", "-resize", "100x100>", "b.jpg"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Resize {
width: 100,
height: 100,
mode: ResizeMode::Shrink,
}]
);
}
#[test]
fn resize_grow_only_suffix() {
let p = parse(&to_vec(&["a.png", "-resize", "1024x1024<", "b.jpg"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Resize {
width: 1024,
height: 1024,
mode: ResizeMode::Grow,
}]
);
}
#[test]
fn resize_percent_two_axis() {
let p = parse(&to_vec(&["a.png", "-resize", "50x200%", "b.jpg"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Resize {
width: 50,
height: 200,
mode: ResizeMode::Percent,
}]
);
}
#[test]
fn resize_percent_single_value_replicates() {
let p = parse(&to_vec(&["a.png", "-resize", "75%", "b.jpg"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Resize {
width: 75,
height: 75,
mode: ResizeMode::Percent,
}]
);
}
#[test]
fn resize_area_at_suffix() {
let p = parse(&to_vec(&["a.png", "-resize", "640x480@", "b.jpg"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Resize {
width: 640,
height: 480,
mode: ResizeMode::Area,
}]
);
}
#[test]
fn thumbnail_with_geometry_modifier() {
let p = parse(&to_vec(&["a.png", "-thumbnail", "128x128^", "b.jpg"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Thumbnail {
width: 128,
height: 128,
mode: ResizeMode::Fill,
}]
);
}
#[test]
fn thumbnail_default_mode() {
let p = parse(&to_vec(&["a.png", "-thumbnail", "100x100", "b.jpg"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Thumbnail {
width: 100,
height: 100,
mode: ResizeMode::Default,
}]
);
}
#[test]
fn define_with_value_round_trips_namespaced_key() {
let p = parse(&to_vec(&[
"a.png",
"-define",
"jpeg:dct-method=float",
"b.jpg",
]))
.unwrap();
assert_eq!(
p.ops,
vec![Op::Define {
key: "jpeg:dct-method".into(),
value: Some("float".into()),
}]
);
}
#[test]
fn define_bare_key_no_value() {
let p = parse(&to_vec(&[
"a.png",
"-define",
"png:strip-comments",
"b.png",
]))
.unwrap();
assert_eq!(
p.ops,
vec![Op::Define {
key: "png:strip-comments".into(),
value: None,
}]
);
}
#[test]
fn define_empty_key_rejected() {
let err = parse(&to_vec(&["a.png", "-define", "=value", "b.png"])).unwrap_err();
assert!(
format!("{err:?}").contains("empty KEY"),
"unexpected error: {err:?}"
);
}
#[test]
fn blur_sigma_defaults_to_half_radius() {
let p = parse(&to_vec(&["a.png", "-blur", "4", "b.jpg"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Blur {
radius: 4,
sigma: 2.0
}]
);
}
#[test]
fn blur_explicit_sigma() {
let p = parse(&to_vec(&["a.png", "-blur", "3x1.5", "b.jpg"])).unwrap();
match p.ops.as_slice() {
[Op::Blur { radius: 3, sigma }] => assert!((sigma - 1.5).abs() < 1e-6),
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn colors_picks_up_prior_dither() {
let p = parse(&to_vec(&[
"a.png",
"-dither",
"floyd_steinberg",
"-colors",
"64",
"b.gif",
]))
.unwrap();
assert_eq!(
p.ops,
vec![Op::Colors {
count: 64,
dither: Dither::FloydSteinberg
}]
);
}
#[test]
fn monochrome_expands_to_gray_2_colors_floyd_steinberg() {
let p = parse(&to_vec(&["a.png", "-monochrome", "b.png"])).unwrap();
assert_eq!(
p.ops,
vec![
Op::Colorspace("gray".to_string()),
Op::Colors {
count: 2,
dither: Dither::FloydSteinberg
},
]
);
}
#[test]
fn monochrome_is_valueless() {
let p = parse(&to_vec(&["a.png", "-monochrome", "b.png"])).unwrap();
assert_eq!(p.input, "a.png");
assert_eq!(p.output, "b.png");
}
#[test]
fn monochrome_ignores_prior_pending_dither() {
let p = parse(&to_vec(&[
"a.png",
"-dither",
"none",
"-monochrome",
"b.png",
]))
.unwrap();
assert_eq!(
p.ops,
vec![
Op::Colorspace("gray".to_string()),
Op::Colors {
count: 2,
dither: Dither::FloydSteinberg
},
]
);
}
#[test]
fn monochrome_keeps_source_order_with_neighbours() {
let p = parse(&to_vec(&[
"a.png",
"-resize",
"100x100",
"-monochrome",
"-strip",
"b.png",
]))
.unwrap();
assert_eq!(
p.ops,
vec![
Op::Resize {
width: 100,
height: 100,
mode: ResizeMode::Default,
},
Op::Colorspace("gray".to_string()),
Op::Colors {
count: 2,
dither: Dither::FloydSteinberg
},
Op::Strip,
]
);
}
#[test]
fn rotate_90_parses() {
let p = parse(&to_vec(&["a.png", "-rotate", "90", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Rotate { degrees: 90 }]);
}
#[test]
fn rotate_negative_270_parses() {
let p = parse(&to_vec(&["a.png", "-rotate", "-270", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Rotate { degrees: -270 }]);
}
#[test]
fn rotate_45_rejected() {
let err = parse(&to_vec(&["a.png", "-rotate", "45", "b.png"])).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("only multiples of 90 supported"),
"unexpected message: {msg}"
);
assert!(msg.contains("got 45"));
}
#[test]
fn rotate_non_integer_rejected() {
let err = parse(&to_vec(&["a.png", "-rotate", "abc", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("not an integer"));
}
#[test]
fn flip_and_flop_are_valueless() {
let p = parse(&to_vec(&["a.png", "-flip", "-flop", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Flip, Op::Flop]);
}
#[test]
fn negate_is_valueless() {
let p = parse(&to_vec(&["a.png", "-negate", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Negate]);
}
#[test]
fn crop_basic() {
let p = parse(&to_vec(&["a.png", "-crop", "10x20+3+4", "b.png"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Crop {
x: 3,
y: 4,
w: 10,
h: 20
}]
);
}
#[test]
fn crop_zero_offset() {
let p = parse(&to_vec(&["a.png", "-crop", "32x32+0+0", "b.png"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Crop {
x: 0,
y: 0,
w: 32,
h: 32
}]
);
}
#[test]
fn crop_missing_offset_rejected() {
let err = parse(&to_vec(&["a.png", "-crop", "32x32", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("missing the +X offset"));
}
#[test]
fn crop_im_modifier_rejected() {
let err = parse(&to_vec(&["a.png", "-crop", "50%+0+0", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("IM modifier"));
}
#[test]
fn extent_default_offset_and_background() {
let p = parse(&to_vec(&["a.png", "-extent", "100x80", "b.png"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Extent {
width: 100,
height: 80,
x: 0,
y: 0,
bg: [255, 255, 255, 255],
}]
);
}
#[test]
fn extent_positive_offsets() {
let p = parse(&to_vec(&["a.png", "-extent", "120x90+10+5", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Extent {
width: 120,
height: 90,
x: 10,
y: 5,
bg: _,
}] => {}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn extent_negative_offsets() {
let p = parse(&to_vec(&["a.png", "-extent", "200x150-25-30", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Extent {
width: 200,
height: 150,
x: -25,
y: -30,
bg: _,
}] => {}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn extent_mixed_signs() {
let p = parse(&to_vec(&["a.png", "-extent", "64x64+10-5", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Extent {
width: 64,
height: 64,
x: 10,
y: -5,
bg: _,
}] => {}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn extent_picks_up_prior_background() {
let p = parse(&to_vec(&[
"a.png",
"-background",
"red",
"-extent",
"50x50",
"b.png",
]))
.unwrap();
assert_eq!(p.ops.len(), 2);
assert!(matches!(p.ops[0], Op::Background([255, 0, 0, 255])));
match &p.ops[1] {
Op::Extent { bg, .. } => assert_eq!(*bg, [255, 0, 0, 255]),
other => panic!("expected Extent, got {other:?}"),
}
}
#[test]
fn extent_ignores_subsequent_background() {
let p = parse(&to_vec(&[
"a.png",
"-extent",
"50x50",
"-background",
"red",
"b.png",
]))
.unwrap();
match &p.ops[0] {
Op::Extent { bg, .. } => assert_eq!(*bg, [255, 255, 255, 255]),
other => panic!("expected Extent, got {other:?}"),
}
}
#[test]
fn extent_two_calls_capture_independent_backgrounds() {
let p = parse(&to_vec(&[
"a.png",
"-background",
"red",
"-extent",
"50x50",
"-background",
"blue",
"-extent",
"60x60",
"b.png",
]))
.unwrap();
let extents: Vec<&Op> = p
.ops
.iter()
.filter(|o| matches!(o, Op::Extent { .. }))
.collect();
assert_eq!(extents.len(), 2);
match extents[0] {
Op::Extent { width: 50, bg, .. } => assert_eq!(*bg, [255, 0, 0, 255]),
other => panic!("unexpected first extent: {other:?}"),
}
match extents[1] {
Op::Extent { width: 60, bg, .. } => assert_eq!(*bg, [0, 0, 255, 255]),
other => panic!("unexpected second extent: {other:?}"),
}
}
#[test]
fn extent_single_offset_rejected() {
let err = parse(&to_vec(&["a.png", "-extent", "64x64+10", "b.png"])).unwrap_err();
assert!(
format!("{err:?}").contains("single offset"),
"unexpected error: {err:?}"
);
}
#[test]
fn extent_im_modifier_rejected() {
let err = parse(&to_vec(&["a.png", "-extent", "50%+0+0", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("IM modifier"));
}
#[test]
fn sharpen_basic() {
let p = parse(&to_vec(&["a.png", "-sharpen", "1x0.5", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Sharpen { radius: 1, sigma }] => assert!((sigma - 0.5).abs() < 1e-6),
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn sharpen_radius_only_defaults_sigma() {
let p = parse(&to_vec(&["a.png", "-sharpen", "4", "b.png"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Sharpen {
radius: 4,
sigma: 2.0
}]
);
}
#[test]
fn unsharp_full_grammar() {
let p = parse(&to_vec(&["a.png", "-unsharp", "2x1.5+0.8+5", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Unsharp {
radius: 2,
sigma,
amount,
threshold: 5,
}] => {
assert!((sigma - 1.5).abs() < 1e-6);
assert!((amount - 0.8).abs() < 1e-6);
}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn unsharp_partial_defaults() {
let p = parse(&to_vec(&["a.png", "-unsharp", "3", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Unsharp {
radius: 3,
sigma,
amount,
threshold: 0,
}] => {
assert!((sigma - 1.5).abs() < 1e-6, "sigma = {sigma}");
assert!((amount - 1.0).abs() < 1e-6, "amount = {amount}");
}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn gamma_basic() {
let p = parse(&to_vec(&["a.png", "-gamma", "1.8", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Gamma { value }] => assert!((value - 1.8).abs() < 1e-6),
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn gamma_zero_rejected() {
let err = parse(&to_vec(&["a.png", "-gamma", "0", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("must be > 0"));
}
#[test]
fn gamma_negative_rejected() {
let err = parse(&to_vec(&["a.png", "-gamma", "-1.5", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("must be > 0"));
}
#[test]
fn brightness_contrast_basic_comma() {
let p = parse(&to_vec(&["a.png", "-brightness-contrast", "10,5", "b.png"])).unwrap();
assert_eq!(
p.ops,
vec![Op::BrightnessContrast {
brightness: 10.0,
contrast: 5.0
}]
);
}
#[test]
fn brightness_contrast_basic_x_separator() {
let p = parse(&to_vec(&[
"a.png",
"-brightness-contrast",
"20x-15",
"b.png",
]))
.unwrap();
assert_eq!(
p.ops,
vec![Op::BrightnessContrast {
brightness: 20.0,
contrast: -15.0
}]
);
}
#[test]
fn brightness_contrast_brightness_only() {
let p = parse(&to_vec(&["a.png", "-brightness-contrast", "30", "b.png"])).unwrap();
assert_eq!(
p.ops,
vec![Op::BrightnessContrast {
brightness: 30.0,
contrast: 0.0
}]
);
}
#[test]
fn brightness_contrast_out_of_range_rejected() {
let err = parse(&to_vec(&[
"a.png",
"-brightness-contrast",
"150,0",
"b.png",
]))
.unwrap_err();
assert!(format!("{err:?}").contains("out of range"));
}
#[test]
fn contrast_step_default_one() {
let p = parse(&to_vec(&["a.png", "-contrast", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Contrast { delta: 1 }]);
}
#[test]
fn contrast_repeated_accumulates_via_chain() {
let p = parse(&to_vec(&["a.png", "-contrast", "-contrast", "b.png"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Contrast { delta: 1 }, Op::Contrast { delta: 1 }]
);
}
#[test]
fn sepia_percent_form() {
let p = parse(&to_vec(&["a.png", "-sepia", "80%", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Sepia { threshold }] => assert!((threshold - 0.8).abs() < 1e-6),
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn sepia_unit_form() {
let p = parse(&to_vec(&["a.png", "-sepia", "0.5", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Sepia { threshold }] => assert!((threshold - 0.5).abs() < 1e-6),
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn modulate_full_triplet() {
let p = parse(&to_vec(&["a.png", "-modulate", "120,80,150", "b.png"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Modulate {
brightness: 120.0,
saturation: 80.0,
hue: 150.0,
}]
);
}
#[test]
fn modulate_brightness_only_defaults_other_components() {
let p = parse(&to_vec(&["a.png", "-modulate", "150", "b.png"])).unwrap();
assert_eq!(
p.ops,
vec![Op::Modulate {
brightness: 150.0,
saturation: 100.0,
hue: 100.0,
}]
);
}
#[test]
fn level_full_triplet() {
let p = parse(&to_vec(&["a.png", "-level", "10/1.2/250", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Level {
black: 10,
gamma,
white: 250,
}] => assert!((gamma - 1.2).abs() < 1e-6),
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn level_percent_endpoints() {
let p = parse(&to_vec(&["a.png", "-level", "10%/1.0/90%", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Level {
black,
gamma: _,
white,
}] => {
assert_eq!(*black, 26);
assert_eq!(*white, 230);
}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn level_inverted_endpoints_rejected() {
let err = parse(&to_vec(&["a.png", "-level", "200/1.0/100", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("must be <="));
}
#[test]
fn normalize_is_valueless() {
let p = parse(&to_vec(&["a.png", "-normalize", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Normalize]);
}
#[test]
fn threshold_percent() {
let p = parse(&to_vec(&["a.png", "-threshold", "50%", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Threshold { value: 128 }]);
}
#[test]
fn threshold_byte() {
let p = parse(&to_vec(&["a.png", "-threshold", "200", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Threshold { value: 200 }]);
}
#[test]
fn posterize_basic() {
let p = parse(&to_vec(&["a.png", "-posterize", "4", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Posterize { levels: 4 }]);
}
#[test]
fn posterize_one_rejected() {
let err = parse(&to_vec(&["a.png", "-posterize", "1", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("levels must be >= 2"));
}
#[test]
fn solarize_percent() {
let p = parse(&to_vec(&["a.png", "-solarize", "100", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Solarize { value: 100 }]);
}
#[test]
fn colorspace_gray_recognised() {
let p = parse(&to_vec(&["a.png", "-colorspace", "Gray", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Colorspace("Gray".into())]);
}
#[test]
fn colorspace_rgb_recognised() {
let p = parse(&to_vec(&["a.png", "-colorspace", "rgb", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Colorspace("rgb".into())]);
}
#[test]
fn colorspace_unsupported_rejected() {
let err = parse(&to_vec(&["a.png", "-colorspace", "CMYK", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("not yet wired"));
}
#[test]
fn unknown_flag_errors() {
let err = parse(&to_vec(&["a.png", "-fnord", "42", "b.png"])).unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("unknown flag"));
assert!(
!msg.contains("did you mean"),
"distant token should not get a hint, got: {msg}"
);
}
#[test]
fn unknown_flag_typo_suggests_close_match() {
let err = parse(&to_vec(&["a.png", "-quailty", "90", "b.jpg"])).unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("unknown flag"), "got: {msg}");
assert!(
msg.contains("did you mean '-quality'?"),
"expected quality suggestion, got: {msg}"
);
}
#[test]
fn unknown_flag_typo_preserves_single_vs_double_dash() {
let err = parse(&to_vec(&["--prbe", "in.png"])).unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("unknown flag"), "got: {msg}");
assert!(
msg.contains("did you mean '--probe'?"),
"expected --probe suggestion with two dashes, got: {msg}"
);
}
#[test]
fn unknown_flag_typo_resize_transposition() {
let err = parse(&to_vec(&["a.png", "-reszie", "100x100", "b.png"])).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("did you mean '-resize'?"),
"expected resize suggestion, got: {msg}"
);
}
#[test]
fn missing_output_errors() {
let err = parse(&to_vec(&["in.png"])).unwrap_err();
assert!(format!("{err:?}").contains("no output file given"));
}
#[test]
fn quality_strip() {
let p = parse(&to_vec(&["a.png", "-quality", "85", "-strip", "b.jpg"])).unwrap();
assert_eq!(p.ops, vec![Op::Quality(85), Op::Strip]);
}
#[test]
fn quality_zero_and_hundred_accepted() {
let p0 = parse(&to_vec(&["a.png", "-quality", "0", "b.jpg"])).unwrap();
assert_eq!(p0.ops, vec![Op::Quality(0)]);
let p100 = parse(&to_vec(&["a.png", "-quality", "100", "b.jpg"])).unwrap();
assert_eq!(p100.ops, vec![Op::Quality(100)]);
}
#[test]
fn quality_out_of_range_rejected() {
let err = parse(&to_vec(&["a.png", "-quality", "1000", "b.jpg"])).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("-quality") && msg.contains("out of range"),
"got: {msg}"
);
assert!(
msg.contains("0..=100"),
"must point at the legal range; got: {msg}"
);
let err = parse(&to_vec(&["a.png", "-quality", "150", "b.jpg"])).unwrap_err();
assert!(format!("{err:?}").contains("out of range"));
}
#[test]
fn quality_non_numeric_rejected() {
let err = parse(&to_vec(&["a.png", "-quality", "-5", "b.jpg"])).unwrap_err();
assert!(format!("{err:?}").contains("not a non-negative integer"));
let err = parse(&to_vec(&["a.png", "-quality", "high", "b.jpg"])).unwrap_err();
assert!(format!("{err:?}").contains("not a non-negative integer"));
}
#[test]
fn density_background_alpha_chain() {
let p = parse(&to_vec(&[
"in.pdf",
"-density",
"300",
"-background",
"white",
"-alpha",
"remove",
"-alpha",
"off",
"page-%03d.png",
]))
.unwrap();
assert_eq!(p.input, "in.pdf");
assert_eq!(p.output, "page-%03d.png");
assert_eq!(
p.ops,
vec![
Op::Density(300),
Op::Background([255, 255, 255, 255]),
Op::Alpha(AlphaOp::Remove),
Op::Alpha(AlphaOp::Off),
]
);
}
#[test]
fn density_zero_rejected() {
let err = parse(&to_vec(&["a.png", "-density", "0", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("-density must be > 0"));
}
#[test]
fn alpha_unknown_subcommand_rejected() {
let err = parse(&to_vec(&["a.png", "-alpha", "fnord", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("unknown subcommand"));
}
#[test]
fn background_hex_color() {
let p = parse(&to_vec(&["a.png", "-background", "#ff8000", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Background([255, 128, 0, 255])]);
}
#[test]
fn background_hex_with_alpha() {
let p = parse(&to_vec(&["a.png", "-background", "#80808040", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Background([128, 128, 128, 64])]);
}
#[test]
fn printf_template_zero_padded() {
let t = parse_printf_template("page-%03d.png").unwrap().unwrap();
assert_eq!(t.prefix, "page-");
assert_eq!(t.width, 3);
assert_eq!(t.suffix, ".png");
}
#[test]
fn printf_template_unpadded() {
let t = parse_printf_template("out%d.jpg").unwrap().unwrap();
assert_eq!(t.prefix, "out");
assert_eq!(t.width, 0);
assert_eq!(t.suffix, ".jpg");
}
#[test]
fn printf_template_absent_returns_none() {
assert!(parse_printf_template("out.png").unwrap().is_none());
assert!(parse_printf_template("/some/dir/out.jpg")
.unwrap()
.is_none());
}
#[test]
fn printf_template_multiple_d_rejected() {
let err = parse_printf_template("page-%d-frame-%d.png").unwrap_err();
assert!(format!("{err:?}").contains("more than one"));
}
#[test]
fn printf_template_unsupported_specifier_rejected() {
let err = parse_printf_template("out-%s.png").unwrap_err();
assert!(format!("{err:?}").contains("unsupported format specifier"));
}
#[test]
fn parse_populates_output_template_when_present() {
let p = parse(&to_vec(&["in.pdf", "page-%02d.png"])).unwrap();
let t = p.output_template.unwrap();
assert_eq!(t.prefix, "page-");
assert_eq!(t.width, 2);
assert_eq!(t.suffix, ".png");
}
#[test]
fn parse_leaves_output_template_none_for_literal_path() {
let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
assert!(p.output_template.is_none());
}
#[test]
fn ping_flag_off_by_default() {
let p = parse(&to_vec(&["in.png", "out.jpg"])).unwrap();
assert!(!p.ping);
}
#[test]
fn ping_flag_set_when_present() {
let p = parse(&to_vec(&["-ping", "in.png", "out.jpg"])).unwrap();
assert!(p.ping);
}
#[test]
fn ping_makes_output_optional() {
let p = parse(&to_vec(&["-ping", "in.png"])).unwrap();
assert!(p.ping);
assert_eq!(p.output, "");
assert!(p.output_template.is_none());
}
#[test]
fn missing_output_without_ping_still_errors() {
let err = parse(&to_vec(&["in.png"])).unwrap_err();
assert!(format!("{err:?}").contains("no output file"));
}
#[test]
fn input_selector_single_page() {
let (path, sel) = split_input_selector("input.pdf[0]").unwrap();
assert_eq!(path, "input.pdf");
assert_eq!(sel, Some(PageSelector::Single(0)));
}
#[test]
fn input_selector_range() {
let (path, sel) = split_input_selector("foo.pdf[2-5]").unwrap();
assert_eq!(path, "foo.pdf");
assert_eq!(sel, Some(PageSelector::Range(2, 5)));
}
#[test]
fn input_selector_absent_passes_through() {
let (path, sel) = split_input_selector("plain.pdf").unwrap();
assert_eq!(path, "plain.pdf");
assert_eq!(sel, None);
}
#[test]
fn input_selector_empty_brackets_rejected() {
assert!(split_input_selector("foo.pdf[]").is_err());
}
#[test]
fn input_selector_non_numeric_rejected() {
assert!(split_input_selector("foo.pdf[abc]").is_err());
assert!(split_input_selector("foo.pdf[1-x]").is_err());
}
#[test]
fn input_selector_extra_dash_rejected() {
assert!(split_input_selector("foo.pdf[1-2-3]").is_err());
}
#[test]
fn parse_pulls_selector_into_input_pages() {
let p = parse(&to_vec(&["in.pdf[0]", "out.png"])).unwrap();
assert_eq!(p.input, "in.pdf");
assert_eq!(p.input_pages, Some(PageSelector::Single(0)));
}
#[test]
fn parse_pulls_range_selector_into_input_pages() {
let p = parse(&to_vec(&["in.pdf[2-4]", "page-%02d.png"])).unwrap();
assert_eq!(p.input, "in.pdf");
assert_eq!(p.input_pages, Some(PageSelector::Range(2, 4)));
assert!(p.output_template.is_some());
}
#[test]
fn input_selector_negative_index_parses() {
let (path, sel) = split_input_selector("foo.pdf[-1]").unwrap();
assert_eq!(path, "foo.pdf");
assert_eq!(sel, Some(PageSelector::Single(-1)));
}
#[test]
fn input_selector_negative_range_endpoint_parses() {
let (path, sel) = split_input_selector("foo.pdf[5--1]").unwrap();
assert_eq!(path, "foo.pdf");
assert_eq!(sel, Some(PageSelector::Range(5, -1)));
}
#[test]
fn input_selector_negative_range_both_endpoints() {
let (path, sel) = split_input_selector("foo.pdf[-3--1]").unwrap();
assert_eq!(path, "foo.pdf");
assert_eq!(sel, Some(PageSelector::Range(-3, -1)));
}
#[test]
fn input_selector_comma_list_of_singles() {
let (path, sel) = split_input_selector("foo.pdf[0,2,4]").unwrap();
assert_eq!(path, "foo.pdf");
assert_eq!(
sel,
Some(PageSelector::List(vec![
PageAtom::Single(0),
PageAtom::Single(2),
PageAtom::Single(4),
]))
);
}
#[test]
fn input_selector_comma_list_with_ranges_and_negatives() {
let (path, sel) = split_input_selector("foo.pdf[0-2,5,-1]").unwrap();
assert_eq!(path, "foo.pdf");
assert_eq!(
sel,
Some(PageSelector::List(vec![
PageAtom::Range(0, 2),
PageAtom::Single(5),
PageAtom::Single(-1),
]))
);
}
#[test]
fn input_selector_single_atom_list_collapses_to_single_variant() {
let (_, sel) = split_input_selector("foo.pdf[3]").unwrap();
assert!(matches!(sel, Some(PageSelector::Single(3))));
}
#[test]
fn input_selector_empty_atom_in_list_rejected() {
assert!(split_input_selector("foo.pdf[0,,1]").is_err());
assert!(split_input_selector("foo.pdf[,0]").is_err());
assert!(split_input_selector("foo.pdf[0,]").is_err());
}
#[test]
fn input_selector_bare_dash_rejected() {
assert!(split_input_selector("foo.pdf[-]").is_err());
}
#[test]
fn page_selector_negative_single_resolves_to_last_page() {
let s = PageSelector::Single(-1);
assert_eq!(s.resolve(5).unwrap(), vec![4]);
let s = PageSelector::Single(-2);
assert_eq!(s.resolve(5).unwrap(), vec![3]);
assert!(PageSelector::Single(-6).resolve(5).is_err());
}
#[test]
fn page_selector_negative_range_endpoint_resolves() {
let s = PageSelector::Range(5, -1);
assert_eq!(s.resolve(8).unwrap(), vec![5, 6, 7]);
let s = PageSelector::Range(-3, -1);
assert_eq!(s.resolve(8).unwrap(), vec![5, 6, 7]);
}
#[test]
fn page_selector_list_resolves_in_source_order() {
let s = PageSelector::List(vec![
PageAtom::Single(0),
PageAtom::Single(2),
PageAtom::Range(4, 5),
PageAtom::Single(-1),
]);
assert_eq!(s.resolve(8).unwrap(), vec![0, 2, 4, 5, 7]);
}
#[test]
fn page_selector_list_preserves_duplicates() {
let s = PageSelector::List(vec![
PageAtom::Single(0),
PageAtom::Single(0),
PageAtom::Single(0),
]);
assert_eq!(s.resolve(3).unwrap(), vec![0, 0, 0]);
}
#[test]
fn page_selector_list_propagates_out_of_range() {
let s = PageSelector::List(vec![PageAtom::Single(0), PageAtom::Single(99)]);
assert!(s.resolve(3).is_err());
}
#[test]
fn vignette_radius_only_defaults_sigma_and_centre() {
let p = parse(&to_vec(&["a.png", "-vignette", "50", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Vignette {
radius,
sigma,
x,
y,
}] => {
assert!((radius - 50.0).abs() < 1e-6);
assert!((sigma - 25.0).abs() < 1e-6);
assert!((x - 0.5).abs() < 1e-6);
assert!((y - 0.5).abs() < 1e-6);
}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn vignette_radius_and_sigma() {
let p = parse(&to_vec(&["a.png", "-vignette", "50+10", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Vignette {
radius,
sigma,
x,
y,
}] => {
assert!((radius - 50.0).abs() < 1e-6);
assert!((sigma - 10.0).abs() < 1e-6);
assert!((x - 0.5).abs() < 1e-6);
assert!((y - 0.5).abs() < 1e-6);
}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn vignette_full_grammar_with_xy() {
let p = parse(&to_vec(&["a.png", "-vignette", "50+10+0.25+0.75", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Vignette {
radius,
sigma,
x,
y,
}] => {
assert!((radius - 50.0).abs() < 1e-6);
assert!((sigma - 10.0).abs() < 1e-6);
assert!((x - 0.25).abs() < 1e-6);
assert!((y - 0.75).abs() < 1e-6);
}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn vignette_too_many_components_rejected() {
let err = parse(&to_vec(&["a.png", "-vignette", "1+2+3+4+5", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("more than 4"));
}
#[test]
fn vignette_negative_radius_rejected() {
let err = parse(&to_vec(&["a.png", "-vignette", "-5", "b.png"])).unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("not a finite float radius") || msg.contains("must be >= 0"));
}
#[test]
fn colorize_named_color_with_percent_amount() {
let p = parse(&to_vec(&["a.png", "-colorize", "red/40%", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Colorize { color, amount }] => {
assert_eq!(*color, [255, 0, 0, 255]);
assert!((amount - 0.4).abs() < 1e-6);
}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn colorize_hex_color() {
let p = parse(&to_vec(&["a.png", "-colorize", "#ff8000/50%", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Colorize { color, amount }] => {
assert_eq!(*color, [255, 128, 0, 255]);
assert!((amount - 0.5).abs() < 1e-6);
}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn colorize_per_channel_triplet() {
let p = parse(&to_vec(&["a.png", "-colorize", "200x100x50/25%", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Colorize { color, amount }] => {
assert_eq!(*color, [200, 100, 50, 255]);
assert!((amount - 0.25).abs() < 1e-6);
}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn colorize_single_channel_replicated() {
let err = parse(&to_vec(&["a.png", "-colorize", "100/50%", "b.png"])).unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("unknown colour") || msg.contains("not a valid hex"));
}
#[test]
fn colorize_per_channel_one_value_replicates() {
let p = parse(&to_vec(&["a.png", "-colorize", "100x100x100/30%", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Colorize { color, amount: _ }] => {
assert_eq!(*color, [100, 100, 100, 255]);
}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn colorize_amount_only_defaults_white() {
let p = parse(&to_vec(&["a.png", "-colorize", "/40%", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Colorize { color, amount }] => {
assert_eq!(*color, [255, 255, 255, 255]);
assert!((amount - 0.4).abs() < 1e-6);
}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn colorize_amount_unit_scalar() {
let p = parse(&to_vec(&["a.png", "-colorize", "red/0.6", "b.png"])).unwrap();
match p.ops.as_slice() {
[Op::Colorize { color, amount }] => {
assert_eq!(*color, [255, 0, 0, 255]);
assert!((amount - 0.6).abs() < 1e-6);
}
other => panic!("unexpected ops: {other:?}"),
}
}
#[test]
fn colorize_out_of_range_channel_rejected() {
let err = parse(&to_vec(&["a.png", "-colorize", "300x0x0/50%", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("out of range"));
}
#[test]
fn equalize_is_valueless() {
let p = parse(&to_vec(&["a.png", "-equalize", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Equalize]);
}
#[test]
fn auto_gamma_is_valueless() {
let p = parse(&to_vec(&["a.png", "-auto-gamma", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::AutoGamma]);
}
#[test]
fn vignette_rejects_negative_sigma() {
let err = parse(&to_vec(&["a.png", "-vignette", "5+-1", "b.png"])).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("not a finite float sigma") || msg.contains("must be >= 0"),
"unexpected message: {msg}"
);
}
#[test]
fn stl_format_default_is_none() {
let p = parse(&to_vec(&["in.stl", "out.stl"])).unwrap();
assert!(p.mesh3d_options.stl_format.is_none());
assert!(p.ops.is_empty(), "no -stl-format → no Op pushed");
}
#[test]
fn stl_format_ascii_parses() {
let p = parse(&to_vec(&["in.stl", "-stl-format", "ascii", "out.stl"])).unwrap();
assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
}
#[test]
fn stl_format_binary_parses() {
let p = parse(&to_vec(&["in.stl", "-stl-format", "binary", "out.stl"])).unwrap();
assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Binary));
}
#[test]
fn stl_format_aliases_parse() {
let p = parse(&to_vec(&["in.stl", "-stl-format", "bin", "out.stl"])).unwrap();
assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Binary));
let p = parse(&to_vec(&["in.stl", "-stl-format", "text", "out.stl"])).unwrap();
assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
}
#[test]
fn stl_format_case_insensitive() {
let p = parse(&to_vec(&["in.stl", "-stl-format", "ASCII", "out.stl"])).unwrap();
assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
}
#[test]
fn stl_format_unknown_value_rejected() {
let err = parse(&to_vec(&["in.stl", "-stl-format", "xml", "out.stl"])).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("unknown flavour 'xml'") && msg.contains("expected 'binary'"),
"unexpected message: {msg}"
);
}
#[test]
fn stl_format_missing_value_rejected() {
let err = parse(&to_vec(&["in.stl", "-stl-format"])).unwrap_err();
assert!(format!("{err:?}").contains("-stl-format: missing value"));
}
#[test]
fn gltf_format_default_is_none() {
let p = parse(&to_vec(&["in.stl", "out.gltf"])).unwrap();
assert!(p.mesh3d_options.gltf_format.is_none());
}
#[test]
fn gltf_format_glb_parses() {
let p = parse(&to_vec(&["in.stl", "-gltf-format", "glb", "out.glb"])).unwrap();
assert_eq!(p.mesh3d_options.gltf_format, Some(GltfFormatChoice::Glb));
}
#[test]
fn gltf_format_embedded_parses() {
let p = parse(&to_vec(&["in.stl", "-gltf-format", "embedded", "out.gltf"])).unwrap();
assert_eq!(
p.mesh3d_options.gltf_format,
Some(GltfFormatChoice::JsonEmbedded)
);
}
#[test]
fn gltf_format_external_parses_even_though_runtime_rejects() {
let p = parse(&to_vec(&["in.stl", "-gltf-format", "external", "out.gltf"])).unwrap();
assert_eq!(
p.mesh3d_options.gltf_format,
Some(GltfFormatChoice::JsonExternal)
);
}
#[test]
fn gltf_format_aliases_parse() {
let p = parse(&to_vec(&["in.stl", "-gltf-format", "binary", "out.glb"])).unwrap();
assert_eq!(p.mesh3d_options.gltf_format, Some(GltfFormatChoice::Glb));
let p = parse(&to_vec(&[
"in.stl",
"-gltf-format",
"json-embedded",
"out.gltf",
]))
.unwrap();
assert_eq!(
p.mesh3d_options.gltf_format,
Some(GltfFormatChoice::JsonEmbedded)
);
}
#[test]
fn gltf_format_unknown_value_rejected() {
let err = parse(&to_vec(&["in.stl", "-gltf-format", "obj", "out.gltf"])).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("unknown flavour 'obj'") && msg.contains("expected 'glb'"),
"unexpected message: {msg}"
);
}
#[test]
fn render_gouraud_parses() {
let p = parse(&to_vec(&["in.stl", "-render", "gouraud", "out.png"])).unwrap();
assert_eq!(
p.mesh3d_options.render_mode,
Some(Mesh3DRenderMode::Gouraud)
);
}
#[test]
fn render_phong_parses() {
let p = parse(&to_vec(&["in.stl", "-render", "phong", "out.png"])).unwrap();
assert_eq!(p.mesh3d_options.render_mode, Some(Mesh3DRenderMode::Phong));
}
#[test]
fn render_wire_alias_parses() {
let p = parse(&to_vec(&["in.stl", "-render", "wire", "out.png"])).unwrap();
assert_eq!(
p.mesh3d_options.render_mode,
Some(Mesh3DRenderMode::Wireframe)
);
}
#[test]
fn render_unknown_mode_rejected_lists_all_options() {
let err = parse(&to_vec(&["in.stl", "-render", "raytrace", "out.png"])).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("unknown mode 'raytrace'")
&& msg.contains("'gouraud'")
&& msg.contains("'phong'"),
"unexpected message: {msg}"
);
}
#[test]
fn light_parses_three_components() {
let p = parse(&to_vec(&["in.stl", "-light", "30,45,1.5", "out.png"])).unwrap();
let light = p.mesh3d_options.light.expect("-light should populate");
assert_eq!(light.azimuth_deg, 30.0);
assert_eq!(light.elevation_deg, 45.0);
assert_eq!(light.intensity, 1.5);
}
#[test]
fn light_rejects_negative_intensity() {
let err = parse(&to_vec(&["in.stl", "-light", "0,0,-1", "out.png"])).unwrap_err();
assert!(format!("{err:?}").contains("intensity"));
}
#[test]
fn light_rejects_two_component_input() {
let err = parse(&to_vec(&["in.stl", "-light", "30,45", "out.png"])).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("AZIMUTH,ELEVATION,INTENSITY"),
"expected grammar hint, got: {msg}"
);
}
#[test]
fn camera_parses_three_components() {
let p = parse(&to_vec(&["in.stl", "-camera", "20,30,2.0", "out.png"])).unwrap();
let cam = p.mesh3d_options.camera.expect("-camera should populate");
assert_eq!(cam.elevation_deg, 20.0);
assert_eq!(cam.azimuth_deg, 30.0);
assert_eq!(cam.distance, 2.0);
}
#[test]
fn camera_rejects_zero_distance() {
let err = parse(&to_vec(&["in.stl", "-camera", "0,0,0", "out.png"])).unwrap_err();
assert!(format!("{err:?}").contains("distance"));
}
#[test]
fn projection_orthographic_parses() {
let p = parse(&to_vec(&[
"in.stl",
"-projection",
"orthographic",
"out.png",
]))
.unwrap();
assert_eq!(
p.mesh3d_options.projection,
Some(ProjectionMode::Orthographic)
);
}
#[test]
fn projection_perspective_default_is_unset() {
let p = parse(&to_vec(&["in.stl", "out.png"])).unwrap();
assert!(p.mesh3d_options.projection.is_none());
}
#[test]
fn projection_alias_parses() {
let p = parse(&to_vec(&["in.stl", "-projection", "ortho", "out.png"])).unwrap();
assert_eq!(
p.mesh3d_options.projection,
Some(ProjectionMode::Orthographic)
);
}
#[test]
fn projection_unknown_rejected() {
let err = parse(&to_vec(&["in.stl", "-projection", "isometric", "out.png"])).unwrap_err();
assert!(format!("{err:?}").contains("unknown mode 'isometric'"));
}
#[test]
fn fov_parses_within_range() {
let p = parse(&to_vec(&["in.stl", "-fov", "45", "out.png"])).unwrap();
assert_eq!(p.mesh3d_options.fov_deg, Some(45.0));
}
#[test]
fn fov_rejects_zero() {
let err = parse(&to_vec(&["in.stl", "-fov", "0", "out.png"])).unwrap_err();
assert!(format!("{err:?}").contains("(0, 180)"));
}
#[test]
fn fov_rejects_180_or_above() {
let err = parse(&to_vec(&["in.stl", "-fov", "180", "out.png"])).unwrap_err();
assert!(format!("{err:?}").contains("(0, 180)"));
}
#[test]
fn bg_named_colour_parses() {
let p = parse(&to_vec(&["in.stl", "-bg", "black", "out.png"])).unwrap();
assert_eq!(p.mesh3d_options.bg, Some([0, 0, 0, 255]));
}
#[test]
fn bg_hex_colour_parses() {
let p = parse(&to_vec(&["in.stl", "-bg", "#abcdef", "out.png"])).unwrap();
assert_eq!(p.mesh3d_options.bg, Some([0xab, 0xcd, 0xef, 255]));
}
#[test]
fn bg_transparent_parses() {
let p = parse(&to_vec(&["in.stl", "-bg", "transparent", "out.png"])).unwrap();
assert_eq!(p.mesh3d_options.bg, Some([0, 0, 0, 0]));
}
#[test]
fn mesh3d_flags_can_appear_before_input() {
let p = parse(&to_vec(&[
"-stl-format",
"ascii",
"-gltf-format",
"glb",
"in.stl",
"out.stl",
]))
.unwrap();
assert_eq!(p.mesh3d_options.stl_format, Some(StlFormatChoice::Ascii));
assert_eq!(p.mesh3d_options.gltf_format, Some(GltfFormatChoice::Glb));
assert_eq!(p.input, "in.stl");
assert_eq!(p.output, "out.stl");
}
#[test]
fn render_normal_debug_parses() {
let p = parse(&to_vec(&["in.stl", "-render", "normal-debug", "out.png"])).unwrap();
assert_eq!(
p.mesh3d_options.render_mode,
Some(Mesh3DRenderMode::NormalDebug)
);
}
#[test]
fn render_depth_debug_parses() {
let p = parse(&to_vec(&["in.stl", "-render", "depth-debug", "out.png"])).unwrap();
assert_eq!(
p.mesh3d_options.render_mode,
Some(Mesh3DRenderMode::DepthDebug)
);
}
#[test]
fn render_normal_alias_parses() {
let p = parse(&to_vec(&["in.stl", "-render", "normal", "out.png"])).unwrap();
assert_eq!(
p.mesh3d_options.render_mode,
Some(Mesh3DRenderMode::NormalDebug)
);
let p = parse(&to_vec(&["in.stl", "-render", "normals", "out.png"])).unwrap();
assert_eq!(
p.mesh3d_options.render_mode,
Some(Mesh3DRenderMode::NormalDebug)
);
}
#[test]
fn render_depth_alias_parses() {
let p = parse(&to_vec(&["in.stl", "-render", "depth", "out.png"])).unwrap();
assert_eq!(
p.mesh3d_options.render_mode,
Some(Mesh3DRenderMode::DepthDebug)
);
let p = parse(&to_vec(&["in.stl", "-render", "z", "out.png"])).unwrap();
assert_eq!(
p.mesh3d_options.render_mode,
Some(Mesh3DRenderMode::DepthDebug)
);
}
#[test]
fn render_unknown_mode_lists_debug_modes() {
let err = parse(&to_vec(&["in.stl", "-render", "raytrace", "out.png"])).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("normal-debug") && msg.contains("depth-debug"),
"expected new debug-mode names in error, got: {msg}"
);
}
#[test]
fn aa_default_is_unset() {
let p = parse(&to_vec(&["in.stl", "out.png"])).unwrap();
assert!(p.mesh3d_options.aa.is_none());
}
#[test]
fn aa_factor_parses() {
let p = parse(&to_vec(&["in.stl", "-aa", "4", "out.png"])).unwrap();
assert_eq!(p.mesh3d_options.aa, Some(4));
}
#[test]
fn aa_factor_one_is_allowed_meaning_no_supersampling() {
let p = parse(&to_vec(&["in.stl", "-aa", "1", "out.png"])).unwrap();
assert_eq!(p.mesh3d_options.aa, Some(1));
}
#[test]
fn aa_factor_zero_rejected() {
let err = parse(&to_vec(&["in.stl", "-aa", "0", "out.png"])).unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("out of range") && msg.contains("1..=8"),
"expected range hint, got: {msg}"
);
}
#[test]
fn aa_factor_above_cap_rejected() {
let err = parse(&to_vec(&["in.stl", "-aa", "16", "out.png"])).unwrap_err();
assert!(format!("{err:?}").contains("1..=8"));
}
#[test]
fn aa_non_integer_rejected() {
let err = parse(&to_vec(&["in.stl", "-aa", "2.5", "out.png"])).unwrap_err();
assert!(format!("{err:?}").contains("not a positive integer"));
}
#[test]
fn trim_default_fuzz_is_zero() {
let p = parse(&to_vec(&["a.png", "-trim", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Trim { fuzz: 0 }]);
}
#[test]
fn fuzz_byte_then_trim_captures_value() {
let p = parse(&to_vec(&["a.png", "-fuzz", "12", "-trim", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Trim { fuzz: 12 }]);
}
#[test]
fn fuzz_percent_then_trim_captures_scaled_value() {
let p = parse(&to_vec(&["a.png", "-fuzz", "10%", "-trim", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Trim { fuzz: 26 }]);
let p = parse(&to_vec(&["a.png", "-fuzz", "100%", "-trim", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Trim { fuzz: 255 }]);
}
#[test]
fn fuzz_after_trim_only_affects_following_trim() {
let p = parse(&to_vec(&[
"a.png", "-fuzz", "5", "-trim", "-fuzz", "20", "-trim", "b.png",
]))
.unwrap();
assert_eq!(p.ops, vec![Op::Trim { fuzz: 5 }, Op::Trim { fuzz: 20 }]);
}
#[test]
fn fuzz_without_trim_is_no_op() {
let p = parse(&to_vec(&["a.png", "-fuzz", "30", "b.png"])).unwrap();
assert_eq!(p.ops, vec![]);
}
#[test]
fn fuzz_missing_value_rejected() {
let err = parse(&to_vec(&["a.png", "-fuzz"])).unwrap_err();
assert!(format!("{err:?}").contains("missing value"));
}
#[test]
fn fuzz_garbage_rejected() {
let err = parse(&to_vec(&["a.png", "-fuzz", "xyz", "-trim", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("-fuzz"));
}
#[test]
fn fuzz_percent_out_of_range_rejected() {
let err = parse(&to_vec(&["a.png", "-fuzz", "150%", "-trim", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("out of range"));
}
#[test]
fn fuzz_byte_above_255_rejected() {
let err = parse(&to_vec(&["a.png", "-fuzz", "300", "-trim", "b.png"])).unwrap_err();
assert!(format!("{err:?}").contains("out of range"));
}
#[test]
fn trim_keeps_source_order_with_neighbours() {
let p = parse(&to_vec(&[
"a.png", "-resize", "100x100", "-fuzz", "8", "-trim", "-strip", "b.png",
]))
.unwrap();
assert_eq!(
p.ops,
vec![
Op::Resize {
width: 100,
height: 100,
mode: ResizeMode::Default,
},
Op::Trim { fuzz: 8 },
Op::Strip,
]
);
}
#[test]
fn roll_positive_offsets() {
let p = parse(&to_vec(&["a.png", "-roll", "+5+10", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Roll { dx: 5, dy: 10 }]);
}
#[test]
fn roll_negative_offsets() {
let p = parse(&to_vec(&["a.png", "-roll", "-3-7", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Roll { dx: -3, dy: -7 }]);
}
#[test]
fn roll_mixed_signs() {
let p = parse(&to_vec(&["a.png", "-roll", "+5-10", "b.png"])).unwrap();
assert_eq!(p.ops, vec![Op::Roll { dx: 5, dy: -10 }]);
}
#[test]
fn roll_single_offset_rejected() {
let err = parse(&to_vec(&["a.png", "-roll", "+5", "b.png"])).unwrap_err();
assert!(
format!("{err:?}").contains("single offset"),
"unexpected error: {err:?}"
);
}
#[test]
fn roll_empty_value_rejected() {
let err = parse(&to_vec(&["a.png", "-roll", "", "b.png"])).unwrap_err();
assert!(
format!("{err:?}").contains("empty offset"),
"unexpected error: {err:?}"
);
}
#[test]
fn roll_unsigned_value_rejected() {
let err = parse(&to_vec(&["a.png", "-roll", "5+10", "b.png"])).unwrap_err();
let s = format!("{err:?}");
assert!(
s.contains("must start with `+` or `-`") || s.contains("not a signed integer"),
"unexpected error: {err:?}"
);
}
#[test]
fn roll_keeps_source_order_with_neighbours() {
let p = parse(&to_vec(&[
"a.png", "-resize", "100x100", "-roll", "+5+5", "-strip", "b.png",
]))
.unwrap();
assert_eq!(
p.ops,
vec![
Op::Resize {
width: 100,
height: 100,
mode: ResizeMode::Default,
},
Op::Roll { dx: 5, dy: 5 },
Op::Strip,
]
);
}
}