#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dither {
None,
Bayer,
FloydSteinberg,
}
impl Dither {
pub fn parse(s: &str) -> Result<Dither, String> {
match s.to_ascii_lowercase().as_str() {
"none" => Ok(Dither::None),
"floyd_steinberg" | "floyd-steinberg" | "floydsteinberg" | "fs" => {
Ok(Dither::FloydSteinberg)
}
"bayer" | "ordered" | "o8x8" => Ok(Dither::Bayer),
other => Err(format!(
"convert: -dither: unknown strategy '{other}' (expected 'none', 'bayer', or 'floyd_steinberg')"
)),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AlphaOp {
On,
Off,
Remove,
Set,
Transparent,
}
impl AlphaOp {
pub fn parse(s: &str) -> Result<AlphaOp, String> {
match s.to_ascii_lowercase().as_str() {
"on" | "activate" => Ok(AlphaOp::On),
"off" | "deactivate" => Ok(AlphaOp::Off),
"remove" => Ok(AlphaOp::Remove),
"set" | "opaque" => Ok(AlphaOp::Set),
"transparent" => Ok(AlphaOp::Transparent),
other => Err(format!(
"convert: -alpha: unknown subcommand '{other}' (expected 'on'/'off'/'activate'/'deactivate'/'remove'/'set'/'opaque'/'transparent')"
)),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ResizeMode {
#[default]
Default,
Force,
Fill,
Shrink,
Grow,
Percent,
Area,
}
impl ResizeMode {
pub fn split_suffix(s: &str) -> (ResizeMode, &str) {
if let Some(core) = s.strip_suffix('!') {
return (ResizeMode::Force, core);
}
if let Some(core) = s.strip_suffix('^') {
return (ResizeMode::Fill, core);
}
if let Some(core) = s.strip_suffix('>') {
return (ResizeMode::Shrink, core);
}
if let Some(core) = s.strip_suffix('<') {
return (ResizeMode::Grow, core);
}
if let Some(core) = s.strip_suffix('%') {
return (ResizeMode::Percent, core);
}
if let Some(core) = s.strip_suffix('@') {
return (ResizeMode::Area, core);
}
(ResizeMode::Default, s)
}
pub fn as_tag(self) -> &'static str {
match self {
ResizeMode::Default => "default",
ResizeMode::Force => "force",
ResizeMode::Fill => "fill",
ResizeMode::Shrink => "shrink",
ResizeMode::Grow => "grow",
ResizeMode::Percent => "percent",
ResizeMode::Area => "area",
}
}
pub fn resolve(self, req_w: u32, req_h: u32, src_w: u32, src_h: u32) -> (u32, u32) {
let sw = src_w.max(1) as f64;
let sh = src_h.max(1) as f64;
let rw = req_w.max(1) as f64;
let rh = req_h.max(1) as f64;
let (out_w, out_h) = match self {
ResizeMode::Default => {
let s = (rw / sw).min(rh / sh);
((sw * s).round(), (sh * s).round())
}
ResizeMode::Force => (rw, rh),
ResizeMode::Fill => {
let s = (rw / sw).max(rh / sh);
((sw * s).round(), (sh * s).round())
}
ResizeMode::Shrink => {
if (src_w <= req_w) && (src_h <= req_h) {
(sw, sh)
} else {
let s = (rw / sw).min(rh / sh);
((sw * s).round(), (sh * s).round())
}
}
ResizeMode::Grow => {
if (src_w >= req_w) && (src_h >= req_h) {
(sw, sh)
} else {
let s = (rw / sw).min(rh / sh);
((sw * s).round(), (sh * s).round())
}
}
ResizeMode::Percent => {
((sw * rw / 100.0).round(), (sh * rh / 100.0).round())
}
ResizeMode::Area => {
let target_area = rw * rh;
let src_area = sw * sh;
let s = (target_area / src_area).sqrt();
((sw * s).round(), (sh * s).round())
}
};
((out_w as i64).max(1) as u32, (out_h as i64).max(1) as u32)
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Op {
Resize {
width: u32,
height: u32,
mode: ResizeMode,
},
Thumbnail {
width: u32,
height: u32,
mode: ResizeMode,
},
Define { key: String, value: Option<String> },
Blur { radius: u32, sigma: f32 },
Edge { radius: u32 },
Colors { count: u32, dither: Dither },
Format(String),
Quality(u32),
Strip,
Density(u32),
Background([u8; 4]),
Alpha(AlphaOp),
Rotate { degrees: i32 },
Flip,
Flop,
Crop { x: u32, y: u32, w: u32, h: u32 },
Negate,
Sharpen { radius: u32, sigma: f32 },
Unsharp {
radius: u32,
sigma: f32,
amount: f32,
threshold: u8,
},
Gamma { value: f32 },
BrightnessContrast { brightness: f32, contrast: f32 },
Contrast { delta: i32 },
Sepia { threshold: f32 },
Modulate {
brightness: f32,
saturation: f32,
hue: f32,
},
Level { black: u8, gamma: f32, white: u8 },
Normalize,
Threshold { value: u8 },
Posterize { levels: u32 },
Solarize { value: u8 },
Colorspace(String),
Vignette {
radius: f32,
sigma: f32,
x: f32,
y: f32,
},
Colorize { color: [u8; 4], amount: f32 },
Equalize,
AutoGamma,
Extent {
width: u32,
height: u32,
x: i32,
y: i32,
bg: [u8; 4],
},
Trim { fuzz: u8 },
Roll { dx: i32, dy: i32 },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PrintfTemplate {
pub prefix: String,
pub width: u8,
pub suffix: String,
}
impl PrintfTemplate {
pub fn expand(&self, n: usize) -> String {
if self.width == 0 {
format!("{}{}{}", self.prefix, n, self.suffix)
} else {
format!(
"{}{:0>width$}{}",
self.prefix,
n,
self.suffix,
width = self.width as usize
)
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PageAtom {
Single(isize),
Range(isize, isize),
}
impl PageAtom {
fn resolve_into(&self, total_pages: usize, out: &mut Vec<usize>) -> Result<(), String> {
match self {
PageAtom::Single(n) => {
let idx = resolve_signed_index(*n, total_pages)?;
out.push(idx);
Ok(())
}
PageAtom::Range(a, b) => {
let aa = resolve_signed_index(*a, total_pages)?;
let bb = resolve_signed_index(*b, total_pages)?;
if aa > bb {
return Err(format!(
"page range [{a}-{b}] resolves to inverted [{aa}-{bb}] (start > end after negative-index resolution)"
));
}
out.extend(aa..=bb);
Ok(())
}
}
}
}
fn resolve_signed_index(n: isize, total_pages: usize) -> Result<usize, String> {
if n >= 0 {
let u = n as usize;
if u >= total_pages {
Err(format!(
"page index {n} out of range (input has {total_pages} page(s))"
))
} else {
Ok(u)
}
} else {
let off = (-n) as usize;
if off > total_pages || off == 0 {
Err(format!(
"negative page index {n} out of range (input has {total_pages} page(s); valid: -1..=-{total_pages})"
))
} else {
Ok(total_pages - off)
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PageSelector {
Single(isize),
Range(isize, isize),
List(Vec<PageAtom>),
}
impl PageSelector {
pub fn resolve(&self, total_pages: usize) -> Result<Vec<usize>, String> {
let mut out = Vec::new();
match self {
PageSelector::Single(n) => {
PageAtom::Single(*n).resolve_into(total_pages, &mut out)?;
}
PageSelector::Range(a, b) => {
PageAtom::Range(*a, *b).resolve_into(total_pages, &mut out)?;
}
PageSelector::List(atoms) => {
for atom in atoms {
atom.resolve_into(total_pages, &mut out)?;
}
}
}
Ok(out)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StlFormatChoice {
Binary,
Ascii,
}
impl StlFormatChoice {
pub fn parse(s: &str) -> Result<StlFormatChoice, String> {
match s.to_ascii_lowercase().as_str() {
"binary" | "bin" => Ok(StlFormatChoice::Binary),
"ascii" | "text" => Ok(StlFormatChoice::Ascii),
other => Err(format!(
"convert: -stl-format: unknown flavour '{other}' (expected 'binary' or 'ascii')"
)),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GltfFormatChoice {
Glb,
JsonEmbedded,
JsonExternal,
}
impl GltfFormatChoice {
pub fn parse(s: &str) -> Result<GltfFormatChoice, String> {
match s.to_ascii_lowercase().as_str() {
"glb" | "binary" => Ok(GltfFormatChoice::Glb),
"embedded" | "json" | "json-embedded" => Ok(GltfFormatChoice::JsonEmbedded),
"external" | "json-external" => Ok(GltfFormatChoice::JsonExternal),
other => Err(format!(
"convert: -gltf-format: unknown flavour '{other}' (expected 'glb', 'embedded', or 'external')"
)),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Mesh3DRenderMode {
#[default]
Flat,
Wireframe,
Gouraud,
Phong,
NormalDebug,
DepthDebug,
}
impl Mesh3DRenderMode {
pub fn parse(s: &str) -> Result<Mesh3DRenderMode, String> {
match s.to_ascii_lowercase().as_str() {
"flat" | "shaded" => Ok(Mesh3DRenderMode::Flat),
"wireframe" | "wire" => Ok(Mesh3DRenderMode::Wireframe),
"gouraud" => Ok(Mesh3DRenderMode::Gouraud),
"phong" => Ok(Mesh3DRenderMode::Phong),
"normal-debug" | "normaldebug" | "normal" | "normals" => {
Ok(Mesh3DRenderMode::NormalDebug)
}
"depth-debug" | "depthdebug" | "depth" | "z" => Ok(Mesh3DRenderMode::DepthDebug),
other => Err(format!(
"convert: -render: unknown mode '{other}' (expected 'flat', 'wireframe', 'gouraud', 'phong', 'normal-debug', or 'depth-debug')"
)),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ProjectionMode {
#[default]
Perspective,
Orthographic,
}
impl ProjectionMode {
pub fn parse(s: &str) -> Result<ProjectionMode, String> {
match s.to_ascii_lowercase().as_str() {
"perspective" | "persp" | "p" => Ok(ProjectionMode::Perspective),
"orthographic" | "ortho" | "o" => Ok(ProjectionMode::Orthographic),
other => Err(format!(
"convert: -projection: unknown mode '{other}' (expected 'perspective' or 'orthographic')"
)),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LightSpec {
pub azimuth_deg: f32,
pub elevation_deg: f32,
pub intensity: f32,
}
impl LightSpec {
pub fn default_light() -> Self {
Self {
azimuth_deg: 45.0,
elevation_deg: 45.0,
intensity: 1.0,
}
}
pub fn parse(s: &str) -> Result<LightSpec, String> {
let parts: Vec<&str> = s.split([',', 'x']).collect();
if parts.len() != 3 {
return Err(format!(
"convert: -light: '{s}' must be 'AZIMUTH,ELEVATION,INTENSITY' (e.g. '45,30,1.0')"
));
}
let az: f32 = parts[0]
.trim()
.parse()
.map_err(|_| format!("convert: -light: azimuth '{}' is not a number", parts[0]))?;
let el: f32 = parts[1]
.trim()
.parse()
.map_err(|_| format!("convert: -light: elevation '{}' is not a number", parts[1]))?;
let intensity: f32 = parts[2]
.trim()
.parse()
.map_err(|_| format!("convert: -light: intensity '{}' is not a number", parts[2]))?;
if !intensity.is_finite() || intensity < 0.0 {
return Err(format!(
"convert: -light: intensity {intensity} must be >= 0.0 and finite"
));
}
Ok(LightSpec {
azimuth_deg: az,
elevation_deg: el,
intensity,
})
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CameraSpec {
pub elevation_deg: f32,
pub azimuth_deg: f32,
pub distance: f32,
}
impl CameraSpec {
pub fn parse(s: &str) -> Result<CameraSpec, String> {
let parts: Vec<&str> = s.split([',', 'x']).collect();
if parts.len() != 3 {
return Err(format!(
"convert: -camera: '{s}' must be 'ELEVATION,AZIMUTH,DISTANCE' (e.g. '20,30,1.5')"
));
}
let el: f32 = parts[0]
.trim()
.parse()
.map_err(|_| format!("convert: -camera: elevation '{}' is not a number", parts[0]))?;
let az: f32 = parts[1]
.trim()
.parse()
.map_err(|_| format!("convert: -camera: azimuth '{}' is not a number", parts[1]))?;
let dist: f32 = parts[2]
.trim()
.parse()
.map_err(|_| format!("convert: -camera: distance '{}' is not a number", parts[2]))?;
if !dist.is_finite() || dist <= 0.0 {
return Err(format!(
"convert: -camera: distance {dist} must be > 0.0 and finite"
));
}
Ok(CameraSpec {
elevation_deg: el,
azimuth_deg: az,
distance: dist,
})
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Mesh3DOptions {
pub stl_format: Option<StlFormatChoice>,
pub gltf_format: Option<GltfFormatChoice>,
pub render_mode: Option<Mesh3DRenderMode>,
pub light: Option<LightSpec>,
pub camera: Option<CameraSpec>,
pub projection: Option<ProjectionMode>,
pub fov_deg: Option<f32>,
pub bg: Option<[u8; 4]>,
pub aa: Option<u32>,
}
#[derive(Clone, Debug)]
pub struct ConvertPlan {
pub input: String,
pub input_pages: Option<PageSelector>,
pub ops: Vec<Op>,
pub output: String,
pub output_template: Option<PrintfTemplate>,
pub ping: bool,
pub probe: bool,
pub probe_json: bool,
pub probe_watch: bool,
pub mesh3d_options: Mesh3DOptions,
}