Skip to main content

falcon_library/
library_icon.rs

1//! The library's own icon, `icon.png` in the library root. Eagle shows it
2//! next to the library name in the library switcher.
3
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7use image::imageops::FilterType;
8
9use crate::{Error, Result};
10
11/// The icon's file name inside the library folder.
12pub const ICON_FILE: &str = "icon.png";
13
14/// The length the icon's shorter side is scaled to, in pixels.
15const SHORT_SIDE: u32 = 64;
16
17/// The icon file, when the library has one.
18pub fn icon_path(root: &Path) -> Option<PathBuf> {
19    let p = root.join(ICON_FILE);
20    p.is_file().then_some(p)
21}
22
23/// Size of an image of `w` × `h` scaled so its shorter side is 64 px.
24fn scaled(w: u32, h: u32) -> (u32, u32) {
25    let k = f64::from(SHORT_SIDE) / f64::from(w.min(h).max(1));
26    let side = |n: u32| ((f64::from(n) * k).round() as u32).max(1);
27    (side(w), side(h))
28}
29
30/// Makes `source` (jpg, jpeg, png, webp or gif, first frame) the library's
31/// icon: scaled uniformly so the shorter side is 64 px, saved as PNG. The
32/// file is written next to `icon.png` and renamed over it, so a failure
33/// leaves the old icon alone.
34pub fn set_icon(root: &Path, source: &Path) -> Result<()> {
35    let refuse = |reason: String| Error::Import { path: source.to_path_buf(), reason };
36    let img = image::open(source).map_err(|e| refuse(format!("not an image falcon can read ({e})")))?;
37    let (w, h) = scaled(img.width(), img.height());
38    let small = image::DynamicImage::ImageRgba8(img.resize_exact(w, h, FilterType::Lanczos3).to_rgba8());
39    let mut png = std::io::Cursor::new(Vec::new());
40    small.write_to(&mut png, image::ImageFormat::Png).map_err(|e| refuse(format!("the icon could not be encoded ({e})")))?;
41
42    let target = root.join(ICON_FILE);
43    let mut tmp = tempfile::Builder::new().prefix(".icon-").suffix(".tmp").tempfile_in(root).map_err(|e| Error::io(root, e))?;
44    tmp.write_all(png.get_ref()).and_then(|()| tmp.flush()).map_err(|e| Error::io(tmp.path(), e))?;
45    tmp.persist(&target).map_err(|e| Error::io(&target, e.error))?;
46    Ok(())
47}
48
49/// Deletes the icon; nothing happens when there is none.
50pub fn remove_icon(root: &Path) -> Result<()> {
51    let target = root.join(ICON_FILE);
52    match std::fs::remove_file(&target) {
53        Ok(()) => Ok(()),
54        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
55        Err(e) => Err(Error::io(target, e)),
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    fn make(dir: &Path, name: &str, w: u32, h: u32) -> PathBuf {
64        let p = dir.join(name);
65        image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255])).save(&p).unwrap();
66        p
67    }
68
69    fn size(p: &Path) -> (u32, u32) {
70        image::image_dimensions(p).unwrap()
71    }
72
73    #[test]
74    fn wide_image_scales_down() {
75        let d = tempfile::tempdir().unwrap();
76        let src = make(d.path(), "a.png", 200, 100);
77        assert_eq!(icon_path(d.path()), None);
78        set_icon(d.path(), &src).unwrap();
79        assert_eq!(size(&icon_path(d.path()).unwrap()), (128, 64));
80    }
81
82    #[test]
83    fn small_image_scales_up() {
84        let d = tempfile::tempdir().unwrap();
85        let src = make(d.path(), "a.png", 32, 48);
86        set_icon(d.path(), &src).unwrap();
87        assert_eq!(size(&d.path().join(ICON_FILE)), (64, 96));
88    }
89
90    #[test]
91    fn extreme_ratio_keeps_one_pixel() {
92        assert_eq!(scaled(1000, 1), (64000, 64));
93        assert_eq!(scaled(1, 1000), (64, 64000));
94        assert_eq!(scaled(64, 64), (64, 64));
95    }
96
97    #[test]
98    fn replaces_the_old_icon_and_leaves_no_temp_file() {
99        let d = tempfile::tempdir().unwrap();
100        set_icon(d.path(), &make(d.path(), "a.png", 64, 64)).unwrap();
101        set_icon(d.path(), &make(d.path(), "b.png", 100, 200)).unwrap();
102        assert_eq!(size(&d.path().join(ICON_FILE)), (64, 128));
103        let stray = std::fs::read_dir(d.path()).unwrap().filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().starts_with(".icon-")).count();
104        assert_eq!(stray, 0);
105    }
106
107    #[test]
108    fn remove_is_idempotent() {
109        let d = tempfile::tempdir().unwrap();
110        remove_icon(d.path()).unwrap();
111        set_icon(d.path(), &make(d.path(), "a.png", 10, 10)).unwrap();
112        remove_icon(d.path()).unwrap();
113        assert_eq!(icon_path(d.path()), None);
114        remove_icon(d.path()).unwrap();
115    }
116
117    #[test]
118    fn a_bad_source_keeps_the_existing_icon() {
119        let d = tempfile::tempdir().unwrap();
120        set_icon(d.path(), &make(d.path(), "a.png", 64, 64)).unwrap();
121        let before = std::fs::read(d.path().join(ICON_FILE)).unwrap();
122        let bad = d.path().join("notes.png");
123        std::fs::write(&bad, b"this is text").unwrap();
124        assert!(set_icon(d.path(), &bad).is_err());
125        assert!(set_icon(d.path(), &d.path().join("missing.png")).is_err());
126        assert_eq!(std::fs::read(d.path().join(ICON_FILE)).unwrap(), before);
127    }
128}