doom-eternal 0.1.0

Rust CLI for the Xylex DOOM Eternal texture and install workflow
Documentation
use std::path::{Path, PathBuf};

use image::{DynamicImage, ImageFormat, RgbaImage, imageops::FilterType};
use tempfile::Builder;

use crate::{
    context::AppContext,
    error::{DoomError, Result},
};

#[derive(Debug, Clone, Copy)]
struct LogoAsset {
    source: &'static str,
    output: &'static str,
    width: u32,
    height: u32,
    padding: u32,
}

const LOGOS: &[LogoAsset] = &[
    LogoAsset {
        source: "XYLEX Group_xylex-group-white-logo-transparent_10.png",
        output: "xylex-wordmark-white.png",
        width: 2048,
        height: 512,
        padding: 64,
    },
    LogoAsset {
        source: "XYLEX_GROUP_ICON_TRANSPARENT_WHITE.png",
        output: "xylex-icon-white.png",
        width: 1024,
        height: 1024,
        padding: 96,
    },
    LogoAsset {
        source: "XYLEX_Group_xylex_icon_black_zoom.png",
        output: "xylex-icon-black.png",
        width: 1024,
        height: 1024,
        padding: 96,
    },
    LogoAsset {
        source: "XYLEX Group_xylex_0.png",
        output: "xylex-square-logo.png",
        width: 1024,
        height: 1024,
        padding: 64,
    },
];

pub fn prepare_logo_assets(
    ctx: &AppContext,
    source_dir: impl AsRef<Path>,
    output_dir: impl AsRef<Path>,
) -> Result<()> {
    let source_root = ctx.repo().repo_path(source_dir);
    let output_root = ctx.repo().repo_path(output_dir);

    for logo in LOGOS {
        let input_path = source_root.join(logo.source);
        let output_path = output_root.join(logo.output);
        save_normalized_png(&input_path, &output_path, logo.width, logo.height, logo.padding)?;
        println!("Wrote {}", output_path.display());
    }

    Ok(())
}

pub fn ensure_default_logo_assets(ctx: &AppContext, output_dir: impl AsRef<Path>) -> Result<()> {
    prepare_logo_assets(ctx, PathBuf::from("assets/source/logos"), output_dir)
}

fn save_normalized_png(
    input_path: &Path,
    output_path: &Path,
    canvas_width: u32,
    canvas_height: u32,
    padding: u32,
) -> Result<()> {
    if !input_path.exists() {
        return Err(DoomError::message(format!(
            "Missing source logo: {}",
            input_path.display()
        )));
    }

    let source = image::open(input_path)?.to_rgba8();
    let usable_width = canvas_width.saturating_sub(padding.saturating_mul(2)).max(1);
    let usable_height = canvas_height.saturating_sub(padding.saturating_mul(2)).max(1);
    let scale = f32::min(
        usable_width as f32 / source.width() as f32,
        usable_height as f32 / source.height() as f32,
    );
    let draw_width = (source.width() as f32 * scale).round().max(1.0) as u32;
    let draw_height = (source.height() as f32 * scale).round().max(1.0) as u32;
    let resized = DynamicImage::ImageRgba8(source)
        .resize(draw_width, draw_height, FilterType::Lanczos3)
        .to_rgba8();

    let mut canvas = RgbaImage::new(canvas_width, canvas_height);
    let draw_x = ((canvas_width as i64 - draw_width as i64) / 2).max(0);
    let draw_y = ((canvas_height as i64 - draw_height as i64) / 2).max(0);
    image::imageops::overlay(&mut canvas, &resized, draw_x, draw_y);

    let Some(parent) = output_path.parent() else {
        return Err(DoomError::message(format!(
            "Output path has no parent directory: {}",
            output_path.display()
        )));
    };
    std::fs::create_dir_all(parent)?;

    let temp_file = Builder::new()
        .prefix(".doom-eternal-logo-")
        .suffix(".png")
        .tempfile_in(parent)?;
    DynamicImage::ImageRgba8(canvas).save_with_format(temp_file.path(), ImageFormat::Png)?;
    temp_file.persist(output_path).map_err(|error| DoomError::Io(error.error))?;
    Ok(())
}