jitter 0.1.0

Add natural handwriting-like variation to digital text
mod font;
mod jitter;
mod svg;

use clap::{Parser, Subcommand};
use std::path::PathBuf;

#[derive(Parser)]
#[command(name = "jitter")]
#[command(about = "Add natural handwriting-like variation to digital text")]
#[command(version)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Render text with per-character random variation
    Render {
        /// Text to render
        text: String,

        /// Path to font file (.ttf or .otf)
        #[arg(short, long)]
        font: PathBuf,

        /// Output file path (.svg)
        #[arg(short, long, default_value = "output.svg")]
        output: PathBuf,

        /// Variation intensity (0.0 to 1.0)
        #[arg(short, long, default_value = "0.5", value_parser = parse_intensity)]
        intensity: f64,

        /// Font size in pixels
        #[arg(short, long, default_value = "48")]
        size: u32,
    },
    /// Bake variation into a font file (generates calt alternates)
    Bake {
        /// Input font file (.ttf or .otf)
        input: PathBuf,

        /// Output font file path
        #[arg(short, long)]
        output: Option<PathBuf>,

        /// Number of alternate glyphs per character
        #[arg(short, long, default_value = "3", value_parser = parse_alternates)]
        alternates: u32,

        /// Variation intensity (0.0 to 1.0)
        #[arg(short, long, default_value = "0.5", value_parser = parse_intensity)]
        intensity: f64,
    },
}

fn parse_alternates(s: &str) -> Result<u32, String> {
    let v: u32 = s.parse().map_err(|e| format!("{e}"))?;
    if v >= 1 {
        Ok(v)
    } else {
        Err("alternates must be at least 1".to_string())
    }
}

fn parse_intensity(s: &str) -> Result<f64, String> {
    let v: f64 = s.parse().map_err(|e| format!("{e}"))?;
    if (0.0..=1.0).contains(&v) {
        Ok(v)
    } else {
        Err(format!("intensity must be between 0.0 and 1.0, got {v}"))
    }
}

fn main() {
    let cli = Cli::parse();

    match cli.command {
        Commands::Render {
            text,
            font: font_path,
            output,
            intensity,
            size,
        } => {
            if text.is_empty() {
                eprintln!("Error: text must not be empty");
                std::process::exit(1);
            }

            let (glyphs, units_per_em) = match font::load_glyphs(&font_path, &text) {
                Ok(v) => v,
                Err(e) => {
                    eprintln!("Error: {e}");
                    std::process::exit(1);
                }
            };

            let commands: Vec<Vec<font::PathCommand>> =
                glyphs.iter().map(|g| g.commands.clone()).collect();
            let jittered = jitter::apply_jitter(&commands, intensity, units_per_em as f64);
            let svg_output = svg::render_svg(&glyphs, &jittered, size, units_per_em);

            if let Err(e) = std::fs::write(&output, &svg_output) {
                eprintln!("Error writing output: {e}");
                std::process::exit(1);
            }

            println!(
                "Rendered \"{}\" -> {} ({} bytes)",
                text,
                output.display(),
                svg_output.len()
            );
        }
        Commands::Bake {
            input,
            output,
            alternates,
            intensity,
        } => {
            let output = output.unwrap_or_else(|| {
                let stem = input.file_stem().and_then(|s| s.to_str()).unwrap_or("font");
                let ext = input.extension().and_then(|s| s.to_str()).unwrap_or("ttf");
                input.with_file_name(format!("{stem}-jittered.{ext}"))
            });
            println!(
                "Baking {} with {} alternates (intensity: {}) -> {}",
                input.display(),
                alternates,
                intensity,
                output.display()
            );
            eprintln!("bake mode is not yet implemented");
            std::process::exit(1);
        }
    }
}