use std::path::{Path, PathBuf};
use image::{imageops::FilterType, DynamicImage, ImageFormat, RgbaImage};
use tempfile::Builder;
use crate::{
cache::files_are_identical,
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,
},
LogoAsset {
source: "XYLEX Group_xylex_0.png",
output: "xylex-square-badge-cover.png",
width: 1024,
height: 1024,
padding: 0,
},
];
const GENERATED_BLACK_COVER_FILE: &str = "solid-black-square.png";
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);
let wrote_file = save_normalized_png(
&input_path,
&output_path,
logo.width,
logo.height,
logo.padding,
)?;
if wrote_file {
println!("Wrote {}", output_path.display());
} else {
println!("Reused {}", output_path.display());
}
}
let black_cover_path = output_root.join(GENERATED_BLACK_COVER_FILE);
let wrote_file = save_generated_solid_black_png(&black_cover_path, 1024, 1024)?;
if wrote_file {
println!("Wrote {}", black_cover_path.display());
} else {
println!("Reused {}", black_cover_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<bool> {
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)?;
if output_path.is_file() && files_are_identical(temp_file.path(), output_path)? {
return Ok(false);
}
temp_file
.persist(output_path)
.map_err(|error| DoomError::Io(error.error))?;
Ok(true)
}
fn save_generated_solid_black_png(
output_path: &Path,
canvas_width: u32,
canvas_height: u32,
) -> Result<bool> {
let canvas = RgbaImage::from_pixel(canvas_width, canvas_height, image::Rgba([0, 0, 0, 255]));
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)?;
if output_path.is_file() && files_are_identical(temp_file.path(), output_path)? {
return Ok(false);
}
temp_file
.persist(output_path)
.map_err(|error| DoomError::Io(error.error))?;
Ok(true)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use tempfile::TempDir;
use super::{prepare_logo_assets, GENERATED_BLACK_COVER_FILE};
use crate::context::AppContext;
#[test]
fn badge_cover_logo_keeps_opaque_edges() {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..");
let ctx = AppContext::from_anchor(&repo_root);
let temp_dir = TempDir::new().expect("tempdir");
prepare_logo_assets(&ctx, PathBuf::from("assets/source/logos"), temp_dir.path())
.expect("prepare logo assets");
let cover = image::open(temp_dir.path().join("xylex-square-badge-cover.png"))
.expect("badge cover image")
.to_rgba8();
assert_eq!(
cover.get_pixel(0, 0)[3],
255,
"badge cover corners should stay opaque so the overlay can fully hide source decals"
);
}
#[test]
fn generated_black_cover_is_opaque_black() {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..");
let ctx = AppContext::from_anchor(&repo_root);
let temp_dir = TempDir::new().expect("tempdir");
prepare_logo_assets(&ctx, PathBuf::from("assets/source/logos"), temp_dir.path())
.expect("prepare logo assets");
let cover = image::open(temp_dir.path().join(GENERATED_BLACK_COVER_FILE))
.expect("generated black cover image")
.to_rgba8();
let pixel = cover.get_pixel(cover.width() / 2, cover.height() / 2);
assert_eq!(
*pixel,
image::Rgba([0, 0, 0, 255]),
"generated cover should be fully opaque black"
);
}
}