use std::time::{SystemTime, UNIX_EPOCH};
use clap::{Args, FromArgMatches};
use image::{DynamicImage, GrayImage, Luma};
use imageproc::geometric_transformations::{Interpolation, rotate_about_center};
use crate::crapifier::{Crapifier, CrapifierEntry};
use crate::error::CrapifyError;
#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
enum ThresholdTiming {
#[value(name = "each-pass")]
EachPass,
#[value(name = "end-of-chain")]
EndOfChain,
}
struct XeroxLevel {
name: &'static str,
slug: &'static str,
description: &'static str,
passes: u32,
contrast: f32,
blur_sigma: f32,
noise: f32,
skew_deg: f32,
jitter: f32,
threshold: Option<u8>,
threshold_timing: ThresholdTiming,
}
#[rustfmt::skip]
const XEROX_LEVELS: &[XeroxLevel] = &[
XeroxLevel { name: "Decent copy", slug: "decent-copy", description: "A copy of a copy. Just a little off.", passes: 1, contrast: 1.15, blur_sigma: 0.4, noise: 3.0, skew_deg: 0.0, jitter: 0.0, threshold: None, threshold_timing: ThresholdTiming::EachPass },
XeroxLevel { name: "Staff meeting handout", slug: "staff-meeting-handout", description: "Three runs through the office copier.", passes: 3, contrast: 1.12, blur_sigma: 0.5, noise: 6.0, skew_deg: 0.0, jitter: 1.0, threshold: None, threshold_timing: ThresholdTiming::EachPass },
XeroxLevel { name: "From a fax from 1994", slug: "from-a-fax-from-1994", description: "Binarized, tilted, fed through eight machines.", passes: 8, contrast: 1.10, blur_sigma: 0.7, noise: 15.0, skew_deg: 0.5, jitter: 0.0, threshold: Some(128), threshold_timing: ThresholdTiming::EachPass },
];
const DEFAULT_PRESET_SLUG: &str = "staff-meeting-handout";
fn default_preset() -> &'static XeroxLevel {
XEROX_LEVELS
.iter()
.find(|l| l.slug == DEFAULT_PRESET_SLUG)
.expect("DEFAULT_PRESET_SLUG must reference a real XEROX_LEVELS row")
}
#[derive(Args, Debug)]
pub struct XeroxArgs {
#[arg(
long,
value_parser = clap::builder::PossibleValuesParser::new(
XEROX_LEVELS.iter().map(|l| l.slug)
),
help = "Named recipe preset (overridable by raw knobs)",
long_help = "Named recipe preset — picks a photocopier-doneness level without tuning every \
knob by hand.\n\n\
Each preset is a complete recipe: passes, contrast, blur, noise, skew, \
jitter, threshold, and threshold-timing at once. Passing an individual knob \
on the command line overrides just that one value; everything else stays \
from the preset. See the PRESETS table at the bottom of `--help` for what \
each preset's recipe actually is."
)]
preset: Option<String>,
#[arg(
long,
value_parser = clap::value_parser!(u32).range(1..),
help = "How many times to run the image through the photocopier (1 or more)",
long_help = "How many copy passes to apply. Each pass blurs, boosts contrast, adds \
toner-speckle noise, and (for some presets) skews or jitters the image — and \
the damage compounds. 1 is 'decent-copy'; 3 is 'staff-meeting-handout' (the \
default); 8 is 'from-a-fax-from-1994'. Contrast in particular compounds as \
per-pass^passes, so even a modest 1.10 over 8 passes lands at ≈2.14× the \
original.\n\n\
Body text in scanned documents starts fragmenting around passes=6 and is \
mostly illegible at passes=8. Override to `--passes 5` if you want a milder \
'one bad fax' look that preserves small-text readability; keep the preset \
default if the destruction is the point."
)]
passes: Option<u32>,
#[arg(
long,
help = "Per-pass contrast (1.0 = unchanged, >1 = punchier)",
long_help = "Per-pass contrast multiplier. 1.0 leaves each pass alone; values above 1.0 \
push darks darker and lights lighter, and the effect compounds across \
passes. 1.12 over 3 passes (staff-meeting-handout) lands at ≈1.40× — the \
recognizable office-copy bump. 1.10 over 8 passes (from-a-fax-from-1994) \
ends at ≈2.14×, heavy. Below 1.0 flattens everything toward mid-gray."
)]
contrast: Option<f32>,
#[arg(
long,
help = "Per-pass gaussian blur sigma in pixels (0 = off, ~0.5 = office copier, ~0.7 = fax)",
long_help = "Per-pass gaussian blur sigma (in pixels). Each pass loses a little fidelity \
to optical blur, the way a real photocopier's scan-then-print cycle does. \
0.0 disables blur entirely. 0.4 (decent-copy) is a slight smear; 0.5 \
(staff-meeting-handout) is the canonical office-copy softness; 0.7 \
(from-a-fax-from-1994) is heavier. Above ~1.5 the image dissolves quickly."
)]
blur: Option<f32>,
#[arg(
long,
help = "Per-pass gaussian noise stddev in 0..=255 pixel units (0 = off)",
long_help = "Per-pass gaussian noise standard deviation, in raw 0..=255 pixel units (not \
a 0..=1 fraction). 0.0 disables noise. 3.0 (decent-copy) is barely-visible \
toner speckle; 6.0 (staff-meeting-handout) is visible grain; 15.0 \
(from-a-fax-from-1994) is heavy fax-style mottling. Beyond ~30 the noise \
dominates the picture. Grain pattern is fresh per run — outputs aren't \
bit-reproducible."
)]
noise: Option<f32>,
#[arg(
long = "skew-deg",
help = "Per-pass random rotation in \u{00b1}D degrees (0 = off)",
long_help = "Per-pass random whole-image rotation in \u{00b1}D degrees. Each pass picks a \
uniform random angle in [-D, +D] and rotates the image about its center; \
dimensions are preserved (any content rotated outside the original frame \
gets clipped, and corners get filled with white). 0.0 disables. 0.5 \
(from-a-fax-from-1994) is the canonical mild fax tilt — enough to read as \
'this was scanned crooked', not a deliberate rotation."
)]
skew_deg: Option<f32>,
#[arg(
long,
help = "Per-pass paper-feed jitter in pixels (0 = off)",
long_help = "Per-pass paper-feed jitter — a per-scanline horizontal offset, smoothed with \
a moving-average filter so adjacent rows shift together. Reads as the kind \
of slow wobble you get when paper feeds unevenly through a copier, not \
per-row static. 0.0 disables. 1.0 (staff-meeting-handout) is a subtle \
one-pixel wobble; higher values look like the paper is actively slipping."
)]
jitter: Option<f32>,
#[arg(
long,
value_parser = clap::value_parser!(u8),
help = "Binarize: pixels above T become white, the rest black (absent = off)",
long_help = "Threshold value (0..=255). When present, the image is binarized: pixels \
above the threshold become white (255), the rest become black (0). 128 \
(from-a-fax-from-1994) is the standard midpoint. Absent means no \
binarization (the photocopier-but-still-grayscale look used by \
'decent-copy' and 'staff-meeting-handout'). See `--threshold-timing` for \
where in the pass chain the binarization lands."
)]
threshold: Option<u8>,
#[arg(
long = "threshold-timing",
value_enum,
help = "Where binarization happens in the chain (only meaningful with --threshold)",
long_help = "Where in the chain the threshold applies. Only meaningful when \
`--threshold` is set (or the preset specifies one); ignored otherwise.\n\n\
`each-pass` (default) binarizes after every pass — preserves text and \
solid subjects against busy backgrounds; the per-pass snap erases thin \
outlines (speech bubbles, fine linework) because the iterated blur+threshold \
cycle wears them away.\n\n\
`end-of-chain` binarizes once at the very end — preserves thin outlines as \
dotty halftone-like linework; the cost is solid subjects can smear into \
busy backgrounds via the accumulated blur.\n\n\
Try `end-of-chain` for memes with thin decorative shapes; stay on \
`each-pass` for photographic content, documents, or anything text-heavy."
)]
threshold_timing: Option<ThresholdTiming>,
}
struct Xorshift64 {
state: u64,
}
impl Xorshift64 {
fn new(seed: u64) -> Self {
Self { state: seed.max(1) }
}
fn next_u64(&mut self) -> u64 {
let mut x = self.state;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.state = x;
x.wrapping_mul(0x2545F4914F6CDD1D)
}
fn next_f32(&mut self) -> f32 {
((self.next_u64() >> 40) as f32) / ((1u32 << 24) as f32)
}
fn next_signed_unit(&mut self) -> f32 {
self.next_f32() * 2.0 - 1.0
}
}
fn blur(img: GrayImage, sigma: f32) -> GrayImage {
if sigma <= 0.0 {
return img;
}
imageproc::filter::gaussian_blur_f32(&img, sigma)
}
fn contrast(mut img: GrayImage, factor: f32) -> GrayImage {
for pixel in img.pixels_mut() {
let l = pixel.0[0] as f32;
pixel.0[0] = ((l - 128.0) * factor + 128.0).clamp(0.0, 255.0) as u8;
}
img
}
fn noisify(mut img: GrayImage, stddev: f32, seed: u64) -> GrayImage {
if stddev <= 0.0 {
return img;
}
imageproc::noise::gaussian_noise_mut(&mut img, 0.0, stddev as f64, seed);
img
}
fn threshold(mut img: GrayImage, t: u8) -> GrayImage {
for pixel in img.pixels_mut() {
pixel.0[0] = if pixel.0[0] > t { 255 } else { 0 };
}
img
}
fn skew(img: GrayImage, max_deg: f32, rng: &mut Xorshift64) -> GrayImage {
if max_deg <= 0.0 {
return img;
}
let theta = rng.next_signed_unit() * max_deg.to_radians();
rotate_about_center(&img, theta, Interpolation::Bilinear, Luma([255]))
}
fn jitter(img: GrayImage, max_offset: f32, rng: &mut Xorshift64) -> GrayImage {
if max_offset <= 0.0 {
return img;
}
let (w, h) = img.dimensions();
if h == 0 || w == 0 {
return img;
}
let h_usize = h as usize;
let w_usize = w as usize;
let window = (h_usize / 16).max(8);
let half = window / 2;
let mut prefix = vec![0.0f32; h_usize + 1];
for i in 0..h_usize {
prefix[i + 1] = prefix[i] + rng.next_signed_unit() * max_offset;
}
let src = img.as_raw();
let mut out = vec![255u8; w_usize * h_usize];
let w_i32 = w as i32;
for y in 0..h_usize {
let lo = y.saturating_sub(half);
let hi = (y + half + 1).min(h_usize);
let mean = (prefix[hi] - prefix[lo]) / (hi - lo) as f32;
let shift = mean.round() as i32;
let dst_start = shift.max(0) as usize;
let dst_end = (w_i32 + shift.min(0)).max(0) as usize;
if dst_end > dst_start {
let n = dst_end - dst_start;
let src_start = (-shift).max(0) as usize;
let src_row = &src[y * w_usize + src_start..y * w_usize + src_start + n];
let dst_row = &mut out[y * w_usize + dst_start..y * w_usize + dst_start + n];
dst_row.copy_from_slice(src_row);
}
}
GrayImage::from_raw(w, h, out).expect("buffer length matches w × h")
}
pub struct Xerox;
impl Crapifier for Xerox {
type Args = XeroxArgs;
fn run(&self, img: DynamicImage, args: &Self::Args) -> Result<DynamicImage, CrapifyError> {
let preset = args
.preset
.as_deref()
.and_then(|slug| XEROX_LEVELS.iter().find(|lvl| lvl.slug == slug))
.unwrap_or_else(default_preset);
let passes = args.passes.unwrap_or(preset.passes);
let con = args.contrast.unwrap_or(preset.contrast);
let blur_sigma = args.blur.unwrap_or(preset.blur_sigma);
let noise_std = args.noise.unwrap_or(preset.noise);
let skew_max = args.skew_deg.unwrap_or(preset.skew_deg);
let jitter_max = args.jitter.unwrap_or(preset.jitter);
let threshold_t = args.threshold.or(preset.threshold);
let threshold_timing = args.threshold_timing.unwrap_or(preset.threshold_timing);
let base_seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1);
let mut rng = Xorshift64::new(base_seed);
let mut g = img.to_luma8();
for _ in 0..passes {
let pass_seed = rng.next_u64();
g = blur(g, blur_sigma);
g = contrast(g, con);
g = noisify(g, noise_std, pass_seed);
g = skew(g, skew_max, &mut rng);
g = jitter(g, jitter_max, &mut rng);
if threshold_timing == ThresholdTiming::EachPass
&& let Some(t) = threshold_t
{
g = threshold(g, t);
}
}
if threshold_timing == ThresholdTiming::EndOfChain
&& let Some(t) = threshold_t
{
g = threshold(g, t);
}
Ok(DynamicImage::ImageLuma8(g).into_rgb8().into())
}
}
const STAGE_ABOUT: &str =
"Run an image through a photocopier a few times: bleached, contrasty, grainy, maybe skewed.";
fn build_help_footer() -> String {
let mut s = String::from(
"Each pass blurs the image a little, pushes contrast, adds toner-speckle noise, and \
(for some presets) skews or jitters before optionally binarizing to pure black-and-white. \
The damage compounds — eight passes through 'from-a-fax-from-1994' is genuinely \
destructive.\n\nPRESETS (via --preset <slug>):\n",
);
let slug_col = XEROX_LEVELS.iter().map(|l| l.slug.len()).max().unwrap_or(0);
let name_col = XEROX_LEVELS
.iter()
.map(|l| l.name.len() + 2)
.max()
.unwrap_or(0);
for lvl in XEROX_LEVELS {
let pass_word = if lvl.passes == 1 { "pass" } else { "passes" };
let quoted = format!("\"{}\"", lvl.name);
s.push_str(&format!(
" {slug:<slug_w$} {quoted:<name_w$} {p} {pw}, contrast {c:.2}, blur {b:.1}, noise {n:.0}. {desc}\n",
slug = lvl.slug,
quoted = quoted,
p = lvl.passes,
pw = pass_word,
c = lvl.contrast,
b = lvl.blur_sigma,
n = lvl.noise,
desc = lvl.description,
slug_w = slug_col,
name_w = name_col,
));
}
s.push_str(
"\nKnobs override preset values when both are supplied:\n \
crapify xerox --preset from-a-fax-from-1994 --passes 4 in.png out.png\n\n\
With no flags, defaults resolve to the 'staff-meeting-handout' preset.",
);
s
}
inventory::submit! {
CrapifierEntry {
name: "xerox",
augment_command: |cmd| {
XeroxArgs::augment_args(
cmd.about(STAGE_ABOUT).after_long_help(build_help_footer()),
)
},
run: |img, matches| {
let args = XeroxArgs::from_arg_matches(matches).map_err(CrapifyError::Clap)?;
Xerox.run(img, &args)
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_preset_slug_points_to_real_row() {
let _ = default_preset();
}
#[test]
fn preset_slugs_are_unique_and_dash_lowercase() {
let mut seen = std::collections::HashSet::new();
for lvl in XEROX_LEVELS {
assert!(
lvl.slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
"slug {:?} has chars outside [a-z0-9-]",
lvl.slug
);
assert!(seen.insert(lvl.slug), "duplicate slug: {:?}", lvl.slug);
}
}
#[test]
fn help_footer_includes_every_preset_name_slug_and_description() {
let s = build_help_footer();
for lvl in XEROX_LEVELS {
assert!(s.contains(lvl.name), "missing display name: {:?}", lvl.name);
assert!(s.contains(lvl.slug), "missing slug: {:?}", lvl.slug);
assert!(
s.contains(lvl.description),
"missing desc: {:?}",
lvl.description
);
}
}
#[test]
fn contrast_identity_at_factor_1() {
let img = GrayImage::from_pixel(4, 4, Luma([42]));
let out = contrast(img.clone(), 1.0);
assert_eq!(out, img);
}
#[test]
fn contrast_fixed_point_at_128() {
let img = GrayImage::from_pixel(4, 4, Luma([128]));
let out = contrast(img.clone(), 2.5);
assert_eq!(out, img);
}
#[test]
fn blur_zero_sigma_is_identity() {
let img = GrayImage::from_pixel(8, 8, Luma([100]));
let out = blur(img.clone(), 0.0);
assert_eq!(out, img);
}
#[test]
fn noisify_zero_is_identity() {
let img = GrayImage::from_pixel(8, 8, Luma([100]));
let out = noisify(img.clone(), 0.0, 42);
assert_eq!(out, img);
}
#[test]
fn noisify_preserves_dimensions() {
let img = GrayImage::from_pixel(32, 24, Luma([100]));
let out = noisify(img, 6.0, 42);
assert_eq!(out.dimensions(), (32, 24));
}
#[test]
fn threshold_boundary_behavior() {
let t: u8 = 128;
let below = threshold(GrayImage::from_pixel(2, 2, Luma([t - 1])), t);
let eq = threshold(GrayImage::from_pixel(2, 2, Luma([t])), t);
let above = threshold(GrayImage::from_pixel(2, 2, Luma([t + 1])), t);
assert_eq!(below.get_pixel(0, 0).0[0], 0);
assert_eq!(eq.get_pixel(0, 0).0[0], 0);
assert_eq!(above.get_pixel(0, 0).0[0], 255);
}
#[test]
fn skew_zero_deg_is_identity() {
let img = GrayImage::from_pixel(8, 8, Luma([200]));
let mut rng = Xorshift64::new(42);
let out = skew(img.clone(), 0.0, &mut rng);
assert_eq!(out, img);
}
#[test]
fn skew_preserves_dimensions() {
let img = GrayImage::from_pixel(32, 24, Luma([100]));
let mut rng = Xorshift64::new(42);
let out = skew(img, 5.0, &mut rng);
assert_eq!(out.dimensions(), (32, 24));
}
#[test]
fn jitter_zero_is_identity() {
let img = GrayImage::from_pixel(16, 16, Luma([100]));
let mut rng = Xorshift64::new(42);
let out = jitter(img.clone(), 0.0, &mut rng);
assert_eq!(out, img);
}
#[test]
fn jitter_preserves_dimensions() {
let img = GrayImage::from_pixel(32, 24, Luma([100]));
let mut rng = Xorshift64::new(42);
let out = jitter(img, 3.0, &mut rng);
assert_eq!(out.dimensions(), (32, 24));
}
#[test]
fn threshold_timing_value_names_are_kebab_case() {
use clap::ValueEnum;
assert_eq!(
ThresholdTiming::EachPass
.to_possible_value()
.unwrap()
.get_name(),
"each-pass"
);
assert_eq!(
ThresholdTiming::EndOfChain
.to_possible_value()
.unwrap()
.get_name(),
"end-of-chain"
);
}
#[test]
fn xorshift_zero_seed_is_handled() {
let mut rng = Xorshift64::new(0);
let a = rng.next_u64();
let b = rng.next_u64();
assert_ne!(a, 0);
assert_ne!(a, b);
}
}