灵符lingfu

ling-icon

↓ 0 downloads

Rasterize SVG/PNG icons to multi-resolution .ico and embed them in Windows executables

lingfu install ling-icon
LicenseApache-2.0 OR MIT
AuthorsLing Lin
READMECode / FilesVersionsDependency GraphDependentsSecurityBuildsSettings

Code / Files (6)

src/lib.rs
//! ling-icon — turn an icon source (`.svg`, `.png`/`.jpg`/`.bmp`, or `.ico`) into
//! a multi-resolution Windows `.ico`, and embed it into an executable from a
//! build script.
//!
//! SVG is rendered faithfully (filters, gradients, blur) via `resvg`; raster
//! inputs are resized with `image`; an existing `.ico` is passed through. The
//! crate is used three ways:
//!   * the Ling toolchain's own `build.rs` (icons for `ling.exe`, `lingfu.exe`),
//!   * the `ling` binary at `ling build` time (rasterizes the user's icon),
//!   * the generated app crate's `build.rs` (embeds the produced `.ico`).

use std::path::Path;

/// Standard icon sizes packed into the `.ico` (Explorer picks the best one).
pub const DEFAULT_SIZES: &[u32] = &[16, 32, 48, 64, 128, 256];

/// Build multi-resolution `.ico` bytes from any supported source file.
pub fn ico_bytes(src: &Path, sizes: &[u32]) -> Result<Vec<u8>, String> {
    let ext = src
        .extension()
        .and_then(|e| e.to_str())
        .map(|e| e.to_ascii_lowercase())
        .unwrap_or_default();

    match ext.as_str() {
        // Already an icon — trust it as-is.
        "ico" => std::fs::read(src).map_err(|e| format!("read {}: {e}", src.display())),
        "svg" | "svgz" => svg_to_ico(src, sizes),
        _ => raster_to_ico(src, sizes),
    }
}

/// Build the `.ico` and write it to `out`.
pub fn write_ico(src: &Path, out: &Path, sizes: &[u32]) -> Result<(), String> {
    let bytes = ico_bytes(src, sizes)?;
    std::fs::write(out, bytes).map_err(|e| format!("write {}: {e}", out.display()))
}

// ── SVG → ICO ────────────────────────────────────────────────────────────────

fn svg_to_ico(src: &Path, sizes: &[u32]) -> Result<Vec<u8>, String> {
    use resvg::{tiny_skia, usvg};

    let data = std::fs::read(src).map_err(|e| format!("read {}: {e}", src.display()))?;
    let opt = usvg::Options::default();
    let tree = usvg::Tree::from_data(&data, &opt)
        .map_err(|e| format!("parse SVG {}: {e}", src.display()))?;
    let svg_size = tree.size();
    let (sw, sh) = (svg_size.width(), svg_size.height());

    let mut dir = ico::IconDir::new(ico::ResourceType::Icon);
    for &n in sizes {
        let n = n.clamp(1, 256);
        let mut pixmap =
            tiny_skia::Pixmap::new(n, n).ok_or_else(|| format!("alloc {n}x{n} pixmap"))?;
        // Scale uniformly to fit the square and centre it.
        let scale = (n as f32 / sw).min(n as f32 / sh);
        let tx = (n as f32 - sw * scale) / 2.0;
        let ty = (n as f32 - sh * scale) / 2.0;
        let transform = tiny_skia::Transform::from_scale(scale, scale).post_translate(tx, ty);
        resvg::render(&tree, transform, &mut pixmap.as_mut());

        // tiny_skia is premultiplied; encode_png writes straight-alpha PNG.
        let png = pixmap
            .encode_png()
            .map_err(|e| format!("encode png: {e}"))?;
        let image = ico::IconImage::read_png(&png[..]).map_err(|e| format!("ico read_png: {e}"))?;
        dir.add_entry(ico::IconDirEntry::encode(&image).map_err(|e| format!("ico encode: {e}"))?);
    }
    let mut buf = Vec::new();
    dir.write(&mut buf).map_err(|e| format!("write ico: {e}"))?;
    Ok(buf)
}

// ── Raster (PNG/JPG/BMP) → ICO ────────────────────────────────────────────────

fn raster_to_ico(src: &Path, sizes: &[u32]) -> Result<Vec<u8>, String> {
    let img = image::open(src).map_err(|e| format!("open {}: {e}", src.display()))?;
    let mut dir = ico::IconDir::new(ico::ResourceType::Icon);
    for &n in sizes {
        let n = n.clamp(1, 256);
        let scaled = img.resize_exact(n, n, image::imageops::FilterType::Lanczos3);
        let rgba = scaled.to_rgba8();
        let image = ico::IconImage::from_rgba_data(n, n, rgba.into_raw());
        dir.add_entry(ico::IconDirEntry::encode(&image).map_err(|e| format!("ico encode: {e}"))?);
    }
    let mut buf = Vec::new();
    dir.write(&mut buf).map_err(|e| format!("write ico: {e}"))?;
    Ok(buf)
}

// ── Any supported source → single PNG (registry/web avatars, not Windows-only) ─

/// Rasterize `src` (`.svg`/`.svgz`, `.png`/`.jpg`/`.bmp`/…, or `.ico`) to a single
/// square PNG at `size`×`size`. Used by fu.ling-lang.org to turn a package
/// manifest's `icon` field into a web-displayable avatar — unlike `ico_bytes`,
/// this never touches Windows resource embedding and always yields one PNG.
pub fn png_bytes(src: &Path, size: u32) -> Result<Vec<u8>, String> {
    let ext = src
        .extension()
        .and_then(|e| e.to_str())
        .map(|e| e.to_ascii_lowercase())
        .unwrap_or_default();

    match ext.as_str() {
        "svg" | "svgz" => svg_to_png(src, size),
        "ico" => ico_to_png(src, size),
        _ => raster_to_png(src, size),
    }
}

fn svg_to_png(src: &Path, size: u32) -> Result<Vec<u8>, String> {
    use resvg::{tiny_skia, usvg};

    let data = std::fs::read(src).map_err(|e| format!("read {}: {e}", src.display()))?;
    let opt = usvg::Options::default();
    let tree = usvg::Tree::from_data(&data, &opt)
        .map_err(|e| format!("parse SVG {}: {e}", src.display()))?;
    let svg_size = tree.size();
    let (sw, sh) = (svg_size.width(), svg_size.height());

    let n = size.clamp(1, 4096);
    let mut pixmap =
        tiny_skia::Pixmap::new(n, n).ok_or_else(|| format!("alloc {n}x{n} pixmap"))?;
    let scale = (n as f32 / sw).min(n as f32 / sh);
    let tx = (n as f32 - sw * scale) / 2.0;
    let ty = (n as f32 - sh * scale) / 2.0;
    let transform = tiny_skia::Transform::from_scale(scale, scale).post_translate(tx, ty);
    resvg::render(&tree, transform, &mut pixmap.as_mut());

    pixmap.encode_png().map_err(|e| format!("encode png: {e}"))
}

fn raster_to_png(src: &Path, size: u32) -> Result<Vec<u8>, String> {
    let img = image::open(src).map_err(|e| format!("open {}: {e}", src.display()))?;
    let n = size.clamp(1, 4096);
    let scaled = img.resize_exact(n, n, image::imageops::FilterType::Lanczos3);
    let mut buf = Vec::new();
    scaled
        .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
        .map_err(|e| format!("encode png: {e}"))?;
    Ok(buf)
}

fn ico_to_png(src: &Path, size: u32) -> Result<Vec<u8>, String> {
    let file = std::fs::File::open(src).map_err(|e| format!("open {}: {e}", src.display()))?;
    let dir = ico::IconDir::read(file).map_err(|e| format!("read ico {}: {e}", src.display()))?;
    let entry = dir
        .entries()
        .iter()
        .min_by_key(|e| (e.width() as i32 - size as i32).abs())
        .ok_or_else(|| format!("{} has no icon frames", src.display()))?;
    let image = entry.decode().map_err(|e| format!("decode ico frame: {e}"))?;
    let (w, h) = (image.width(), image.height());
    let rgba = image::RgbaImage::from_raw(w, h, image.rgba_data().to_vec())
        .ok_or_else(|| "ico frame has inconsistent dimensions".to_string())?;
    let dyn_img = image::DynamicImage::ImageRgba8(rgba);
    let n = size.clamp(1, 4096);
    let scaled = dyn_img.resize_exact(n, n, image::imageops::FilterType::Lanczos3);
    let mut buf = Vec::new();
    scaled
        .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
        .map_err(|e| format!("encode png: {e}"))?;
    Ok(buf)
}

// ── Build-script embedding ────────────────────────────────────────────────────

/// From a **build script**, embed `ico_path` as the executable icon for every
/// binary in the current crate. No-op on non-Windows targets.
#[cfg(windows)]
pub fn embed_in_build(ico_path: &Path) -> Result<(), String> {
    let path = ico_path.to_str().ok_or("icon path is not valid UTF-8")?;
    let mut res = winresource::WindowsResource::new();
    res.set_icon(path);
    res.compile()
        .map_err(|e| format!("winresource compile: {e}"))
}

#[cfg(not(windows))]
pub fn embed_in_build(_ico_path: &Path) -> Result<(), String> {
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use resvg::{tiny_skia, usvg};

    // Render an inline SVG straight to ICO bytes (mirrors `svg_to_ico` without a
    // file on disk) and assert the output is a well-formed icon.
    fn inline_svg_to_ico(svg: &str, sizes: &[u32]) -> Vec<u8> {
        let opt = usvg::Options::default();
        let tree = usvg::Tree::from_data(svg.as_bytes(), &opt).expect("parse svg");
        let sz = tree.size();
        let mut dir = ico::IconDir::new(ico::ResourceType::Icon);
        for &n in sizes {
            let mut pm = tiny_skia::Pixmap::new(n, n).unwrap();
            let scale = (n as f32 / sz.width()).min(n as f32 / sz.height());
            resvg::render(
                &tree,
                tiny_skia::Transform::from_scale(scale, scale),
                &mut pm.as_mut(),
            );
            let png = pm.encode_png().unwrap();
            let img = ico::IconImage::read_png(&png[..]).unwrap();
            dir.add_entry(ico::IconDirEntry::encode(&img).unwrap());
        }
        let mut buf = Vec::new();
        dir.write(&mut buf).unwrap();
        buf
    }

    #[test]
    fn svg_renders_to_valid_ico() {
        let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
            <rect width="100" height="100" fill="#2244ff"/>
            <circle cx="50" cy="50" r="30" fill="#ffcc00"/>
        </svg>"##;
        let ico = inline_svg_to_ico(svg, &[16, 32, 48]);
        // ICONDIR header: reserved=0, type=1 (icon), count=3.
        assert_eq!(&ico[0..2], &[0, 0], "reserved field");
        assert_eq!(&ico[2..4], &[1, 0], "type = icon");
        assert_eq!(&ico[4..6], &[3, 0], "three images packed");
        assert!(ico.len() > 100, "ico should contain image data");
    }
}

/// Convenience for build scripts: rasterize `src` → `OUT_DIR/<stem>.ico` and
/// embed it. Returns the path of the produced `.ico`. Errors are returned so the
/// caller can warn-and-continue (a missing icon should never fail a build).
pub fn rasterize_and_embed(src: &Path, out_dir: &Path) -> Result<std::path::PathBuf, String> {
    let stem = src.file_stem().and_then(|s| s.to_str()).unwrap_or("app");
    let out = out_dir.join(format!("{stem}.ico"));
    write_ico(src, &out, DEFAULT_SIZES)?;
    embed_in_build(&out)?;
    Ok(out)
}

← Back to index