#[derive(Copy, Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Mode {
Automatic,
Standard,
Reduced,
CommonOnly,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Ratio {
Common(CommonRatio),
Reduced { num: u32, den: u32 },
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum CommonRatio {
R16x9,
R4x3,
R1x1,
R21x9,
R16x10,
R5x4,
R3x2,
R2x1,
R9x16,
R3x4,
}
impl CommonRatio {
pub fn ratio(self) -> (u32, u32) {
match self {
CommonRatio::R16x9 => (16, 9),
CommonRatio::R4x3 => (4, 3),
CommonRatio::R1x1 => (1, 1),
CommonRatio::R21x9 => (21, 9),
CommonRatio::R16x10 => (16, 10),
CommonRatio::R5x4 => (5, 4),
CommonRatio::R3x2 => (3, 2),
CommonRatio::R2x1 => (2, 1),
CommonRatio::R9x16 => (9, 16),
CommonRatio::R3x4 => (3, 4),
}
}
pub fn label(self) -> &'static str {
match self {
CommonRatio::R16x9 => "16:9",
CommonRatio::R4x3 => "4:3",
CommonRatio::R1x1 => "1:1",
CommonRatio::R21x9 => "21:9",
CommonRatio::R16x10 => "16:10",
CommonRatio::R5x4 => "5:4",
CommonRatio::R3x2 => "3:2",
CommonRatio::R2x1 => "2:1",
CommonRatio::R9x16 => "9:16",
CommonRatio::R3x4 => "3:4",
}
}
pub const ALL: &'static [CommonRatio] = &[
CommonRatio::R16x9,
CommonRatio::R4x3,
CommonRatio::R1x1,
CommonRatio::R21x9,
CommonRatio::R16x10,
CommonRatio::R5x4,
CommonRatio::R3x2,
CommonRatio::R2x1,
CommonRatio::R9x16,
CommonRatio::R3x4,
];
}
pub fn classify(width: u32, height: u32, mode: Mode, tolerance: f32) -> Option<Ratio> {
if width == 0 || height == 0 {
return None;
}
match mode {
Mode::Reduced => Some(reduced(width, height)),
Mode::Standard => closest_common(width, height).map(Ratio::Common),
Mode::Automatic => match nearest_common(width, height, tolerance) {
Some(c) => Some(Ratio::Common(c)),
None => Some(reduced(width, height)),
},
Mode::CommonOnly => nearest_common(width, height, tolerance).map(Ratio::Common),
}
}
fn reduced(width: u32, height: u32) -> Ratio {
let g = gcd(width, height);
Ratio::Reduced {
num: width / g,
den: height / g,
}
}
fn closest_common(width: u32, height: u32) -> Option<CommonRatio> {
let target = width as f32 / height as f32;
CommonRatio::ALL.iter().copied().min_by(|a, b| {
let (an, ad) = a.ratio();
let (bn, bd) = b.ratio();
let aerr = ((an as f32 / ad as f32) - target).abs();
let berr = ((bn as f32 / bd as f32) - target).abs();
aerr.partial_cmp(&berr).unwrap_or(std::cmp::Ordering::Equal)
})
}
fn nearest_common(width: u32, height: u32, tolerance: f32) -> Option<CommonRatio> {
let target = width as f32 / height as f32;
CommonRatio::ALL
.iter()
.copied()
.filter(|c| {
let (n, d) = c.ratio();
let r = n as f32 / d as f32;
(r - target).abs() / target <= tolerance
})
.min_by(|a, b| {
let (an, ad) = a.ratio();
let (bn, bd) = b.ratio();
let aerr = ((an as f32 / ad as f32) - target).abs();
let berr = ((bn as f32 / bd as f32) - target).abs();
aerr.partial_cmp(&berr).unwrap_or(std::cmp::Ordering::Equal)
})
}
fn gcd(a: u32, b: u32) -> u32 {
if b == 0 { a } else { gcd(b, a % b) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reduced_returns_lowest_terms() {
let r = classify(1920, 1080, Mode::Reduced, 0.0).unwrap();
assert_eq!(r, Ratio::Reduced { num: 16, den: 9 });
}
#[test]
fn automatic_snaps_to_common_within_tolerance() {
let r = classify(1918, 1080, Mode::Automatic, 0.02).unwrap();
assert_eq!(r, Ratio::Common(CommonRatio::R16x9));
}
#[test]
fn automatic_falls_back_to_reduced_when_far_from_common() {
let r = classify(7, 5, Mode::Automatic, 0.02).unwrap();
assert_eq!(r, Ratio::Reduced { num: 7, den: 5 });
}
#[test]
fn common_only_returns_none_when_no_match() {
let r = classify(7, 5, Mode::CommonOnly, 0.02);
assert_eq!(r, None);
}
#[test]
fn standard_always_picks_closest_common() {
let r = classify(7, 5, Mode::Standard, 0.0).unwrap();
assert_eq!(r, Ratio::Common(CommonRatio::R4x3));
}
#[test]
fn handles_portrait_orientations() {
let r = classify(1080, 1920, Mode::Automatic, 0.02).unwrap();
assert_eq!(r, Ratio::Common(CommonRatio::R9x16));
}
#[test]
fn square_is_one_to_one() {
let r = classify(500, 500, Mode::Automatic, 0.02).unwrap();
assert_eq!(r, Ratio::Common(CommonRatio::R1x1));
}
#[test]
fn zero_dim_returns_none() {
assert_eq!(classify(0, 100, Mode::Automatic, 0.02), None);
assert_eq!(classify(100, 0, Mode::Automatic, 0.02), None);
}
}