use std::fs;
use oxideav_core::{Error, PixelFormat, Result, VideoFrame, VideoPlane};
use oxideav_ico::{write_ico, IconImage, IconType, WriteOptions};
use oxideav_image_filter::{ImageFilter, Resize, VideoStreamParams};
use crate::op::{ConvertPlan, Op};
use crate::raster_io::RgbaImage;
pub fn is_ico_output(output: &str) -> bool {
let ext = output.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
ext == "ico"
}
pub fn run(plan: &ConvertPlan) -> Result<()> {
let src = read_source_to_rgba(&plan.input)?;
let sizes = parse_auto_resize_define(&plan.ops)?;
let images: Vec<IconImage> = if sizes.is_empty() {
let (w, h) = (src.width, src.height);
validate_size(w, h)?;
let rgba = ensure_rgba(src);
vec![IconImage::from_rgba(w, h, rgba.pixels)]
} else {
let mut out = Vec::with_capacity(sizes.len());
for &s in &sizes {
let scaled = resize_to_square(&src, s)?;
out.push(IconImage::from_rgba(s, s, scaled.pixels));
}
out
};
let bytes = write_ico(IconType::Ico, &images, WriteOptions::default())
.map_err(|e| Error::invalid(format!("convert: ICO encode failed: {e:?}")))?;
fs::write(&plan.output, bytes)
.map_err(|e| Error::invalid(format!("convert: failed to write {}: {e}", plan.output)))?;
Ok(())
}
fn read_source_to_rgba(input: &str) -> Result<RgbaImage> {
let bytes = fs::read(input)
.map_err(|e| Error::invalid(format!("convert: failed to read {input}: {e}")))?;
let ext = input.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
match ext.as_str() {
"png" | "apng" => decode_png(&bytes),
"bmp" | "dib" => decode_bmp(&bytes),
other => Err(Error::unsupported(format!(
"convert: ICO writer: input extension '.{other}' not yet supported (today png/bmp); convert to png first"
))),
}
}
fn decode_png(bytes: &[u8]) -> Result<RgbaImage> {
let bmp = oxideav_png::decode_png_to_rgba(bytes)
.map_err(|e| Error::invalid(format!("convert: PNG decode failed: {e:?}")))?;
let stride = (bmp.width as usize) * 4;
Ok(RgbaImage {
width: bmp.width,
height: bmp.height,
pixels: bmp.data,
stride,
})
}
fn decode_bmp(bytes: &[u8]) -> Result<RgbaImage> {
use oxideav_bmp::{decode_bmp as bmp_decode, BmpPixelFormat};
let img = bmp_decode(bytes)
.map_err(|e| Error::invalid(format!("convert: BMP decode failed: {e:?}")))?;
let plane = img
.planes
.into_iter()
.next()
.ok_or_else(|| Error::invalid("convert: BMP decoded to zero planes"))?;
let (pixels, stride) = match img.pixel_format {
BmpPixelFormat::Rgba => (plane.data, plane.stride),
BmpPixelFormat::Rgb24 => {
let w = img.width as usize;
let h = img.height as usize;
let mut out = Vec::with_capacity(w * h * 4);
for row in 0..h {
let r0 = row * plane.stride;
for px in plane.data[r0..r0 + w * 3].chunks_exact(3) {
out.extend_from_slice(&[px[0], px[1], px[2], 255]);
}
}
(out, w * 4)
}
other => {
return Err(Error::unsupported(format!(
"convert: BMP decoded to {other:?}, only Rgb24/Rgba supported on ICO path"
)));
}
};
Ok(RgbaImage {
width: img.width,
height: img.height,
pixels,
stride,
})
}
fn ensure_rgba(img: RgbaImage) -> RgbaImage {
if !img.is_rgb() {
return img;
}
let w = img.width as usize;
let h = img.height as usize;
let mut out = Vec::with_capacity(w * h * 4);
for px in img.pixels.chunks_exact(3) {
out.extend_from_slice(&[px[0], px[1], px[2], 255]);
}
RgbaImage {
width: img.width,
height: img.height,
pixels: out,
stride: w * 4,
}
}
pub(crate) fn parse_auto_resize_define(ops: &[Op]) -> Result<Vec<u32>> {
let value = ops.iter().rev().find_map(|o| match o {
Op::Define { key, value } if key.eq_ignore_ascii_case("icon:auto-resize") => {
Some(value.clone())
}
_ => None,
});
let raw = match value {
Some(Some(s)) => s,
Some(None) => {
return Err(Error::invalid(
"convert: -define icon:auto-resize requires a value (e.g. 'icon:auto-resize=16,32,48')",
));
}
None => return Ok(Vec::new()),
};
let mut sizes: Vec<u32> = Vec::new();
for piece in raw.split(',') {
let t = piece.trim();
if t.is_empty() {
continue;
}
let n: u32 = t.parse().map_err(|_| {
Error::invalid(format!(
"convert: -define icon:auto-resize: '{t}' is not a positive integer"
))
})?;
validate_size(n, n)?;
sizes.push(n);
}
if sizes.is_empty() {
return Err(Error::invalid(
"convert: -define icon:auto-resize: value list is empty",
));
}
sizes.sort_unstable();
sizes.dedup();
Ok(sizes)
}
fn validate_size(w: u32, h: u32) -> Result<()> {
if w == 0 || h == 0 || w > 256 || h > 256 {
return Err(Error::invalid(format!(
"convert: ICO size {w}×{h} out of range (each axis must be in 1..=256)"
)));
}
Ok(())
}
fn resize_to_square(src: &RgbaImage, n: u32) -> Result<RgbaImage> {
let src = ensure_rgba(src.clone());
if src.width == n && src.height == n {
return Ok(src);
}
let format = PixelFormat::Rgba;
let in_w = src.width;
let in_h = src.height;
let frame = VideoFrame {
pts: None,
planes: vec![VideoPlane {
stride: src.stride,
data: src.pixels,
}],
};
let params = VideoStreamParams {
format,
width: in_w,
height: in_h,
};
let filter = Resize::new(n, n);
let out = filter
.apply(&frame, params)
.map_err(|e| Error::invalid(format!("convert: Resize for ICO failed: {e:?}")))?;
let plane = out
.planes
.into_iter()
.next()
.ok_or_else(|| Error::invalid("convert: Resize returned no planes"))?;
Ok(RgbaImage {
width: n,
height: n,
pixels: plane.data,
stride: plane.stride,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn op_define(key: &str, value: Option<&str>) -> Op {
Op::Define {
key: key.to_string(),
value: value.map(str::to_string),
}
}
#[test]
fn parse_auto_resize_simple_list() {
let ops = vec![op_define("icon:auto-resize", Some("16,32,48,64,128,256"))];
assert_eq!(
parse_auto_resize_define(&ops).unwrap(),
vec![16, 32, 48, 64, 128, 256]
);
}
#[test]
fn parse_auto_resize_sorts_and_dedups() {
let ops = vec![op_define("icon:auto-resize", Some("256,16,32,16,128"))];
assert_eq!(
parse_auto_resize_define(&ops).unwrap(),
vec![16, 32, 128, 256]
);
}
#[test]
fn parse_auto_resize_absent_returns_empty() {
let ops = vec![Op::Quality(85)];
assert!(parse_auto_resize_define(&ops).unwrap().is_empty());
}
#[test]
fn parse_auto_resize_rejects_zero_and_oversize() {
for v in ["0", "257", "1000"] {
let ops = vec![op_define("icon:auto-resize", Some(v))];
assert!(parse_auto_resize_define(&ops).is_err(), "v={v}");
}
}
#[test]
fn parse_auto_resize_rejects_non_numeric() {
let ops = vec![op_define("icon:auto-resize", Some("16,thirty-two,48"))];
assert!(parse_auto_resize_define(&ops).is_err());
}
#[test]
fn parse_auto_resize_bare_key_errors() {
let ops = vec![op_define("icon:auto-resize", None)];
assert!(parse_auto_resize_define(&ops).is_err());
}
#[test]
fn parse_auto_resize_empty_value_errors() {
let ops = vec![op_define("icon:auto-resize", Some(""))];
assert!(parse_auto_resize_define(&ops).is_err());
let ops = vec![op_define("icon:auto-resize", Some(",,,"))];
assert!(parse_auto_resize_define(&ops).is_err());
}
#[test]
fn parse_auto_resize_case_insensitive_key() {
let ops = vec![op_define("ICON:Auto-Resize", Some("16,32"))];
assert_eq!(parse_auto_resize_define(&ops).unwrap(), vec![16, 32]);
}
#[test]
fn parse_auto_resize_last_wins() {
let ops = vec![
op_define("icon:auto-resize", Some("16,32")),
op_define("icon:auto-resize", Some("64,128")),
];
assert_eq!(parse_auto_resize_define(&ops).unwrap(), vec![64, 128]);
}
#[test]
fn is_ico_output_matches_case_insensitively() {
assert!(is_ico_output("out.ico"));
assert!(is_ico_output("OUT.ICO"));
assert!(is_ico_output("path/to/out.ico"));
assert!(!is_ico_output("out.png"));
assert!(!is_ico_output("out.cur"));
assert!(!is_ico_output("noext"));
}
#[test]
fn validate_size_bounds() {
assert!(validate_size(1, 1).is_ok());
assert!(validate_size(256, 256).is_ok());
assert!(validate_size(0, 32).is_err());
assert!(validate_size(32, 0).is_err());
assert!(validate_size(257, 32).is_err());
assert!(validate_size(32, 257).is_err());
}
}