Skip to main content

falcon_library/
webp_convert.rs

1//! Converting WebP items to PNG or JPG (Eagle's Convert WebP, offered in the
2//! item menu when the item is a WebP).
3//!
4//! What Eagle does, which falcon follows: animated WebPs are skipped; the
5//! picture is decoded and written as PNG, or as JPG at full quality, under
6//! the item's name with the new extension; the WebP goes; the item takes the
7//! new extension and file size. Its thumbnail stays, except that an image
8//! no larger than [`NO_THUMBNAIL_SIZE`] on both sides loses its thumbnail
9//! and shows its original instead. Dimensions, palette, tags and everything
10//! else stay.
11//!
12//! falcon differs in three ways. The new file is written next to the old
13//! one under a temporary name, flushed, and swapped in only once complete;
14//! if saving the item fails, the old file comes back. The old WebP is not
15//! deleted but handed to the caller's `discard` (the app moves it to the
16//! Trash), so a conversion can be undone by hand. And a transparent WebP
17//! converted to JPG is laid on white (Eagle's canvas turns transparency
18//! black). The undo step is marked irreversible, as for other edits that
19//! rewrite the original.
20
21use std::fs;
22use std::io::Write;
23use std::path::{Path, PathBuf};
24
25use image::ImageEncoder;
26
27use crate::error::{Error, Result};
28use crate::library::Library;
29use crate::thumbnail::NO_THUMBNAIL_SIZE;
30
31/// The formats Eagle converts WebP to.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Format {
34    Png,
35    Jpg,
36}
37
38impl Format {
39    pub fn ext(self) -> &'static str {
40        match self {
41            Format::Png => "png",
42            Format::Jpg => "jpg",
43        }
44    }
45}
46
47/// What converting one item did.
48#[derive(Debug, Clone, PartialEq)]
49pub enum Converted {
50    /// Converted; the old WebP went to `discard`.
51    Done,
52    /// Converted, but `discard` failed, so the old WebP was left at this
53    /// (hidden) path in the item's folder.
54    KeptOld(PathBuf),
55    /// An animated WebP, left alone.
56    Animated,
57    /// Not a WebP item.
58    NotWebp,
59}
60
61/// Whether WebP bytes hold an animation (an `ANMF` frame chunk), as Eagle
62/// tests it.
63pub fn is_animated(bytes: &[u8]) -> bool {
64    bytes.windows(4).any(|w| w == b"ANMF")
65}
66
67/// Encodes `img` in `format`: PNG as decoded, JPG at quality 100 with any
68/// transparency laid on white.
69pub fn encode(img: &image::DynamicImage, format: Format) -> std::result::Result<Vec<u8>, String> {
70    let mut out = Vec::new();
71    match format {
72        Format::Png => {
73            img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).map_err(|e| e.to_string())?;
74        }
75        Format::Jpg => {
76            let rgba = img.to_rgba8();
77            let mut rgb = image::RgbImage::new(rgba.width(), rgba.height());
78            for (d, s) in rgb.pixels_mut().zip(rgba.pixels()) {
79                let a = s[3] as u32;
80                for c in 0..3 {
81                    d[c] = ((s[c] as u32 * a + 255 * (255 - a) + 127) / 255) as u8;
82                }
83            }
84            image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 100)
85                .write_image(rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8)
86                .map_err(|e| e.to_string())?;
87        }
88    }
89    Ok(out)
90}
91
92impl Library {
93    /// Converts WebP item `id` to `format` (see the module docs). `discard`
94    /// receives the old WebP once the item is saved.
95    pub fn convert_webp(&mut self, id: &str, format: Format, discard: impl FnOnce(&Path) -> std::io::Result<()>) -> Result<Converted> {
96        let mut item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
97        if !item.ext.eq_ignore_ascii_case("webp") {
98            return Ok(Converted::NotWebp);
99        }
100        let old = item.original_path(self.root());
101        let bytes = fs::read(&old).map_err(|e| Error::io(&old, e))?;
102        if is_animated(&bytes) {
103            return Ok(Converted::Animated);
104        }
105        let img = image::load_from_memory_with_format(&bytes, image::ImageFormat::WebP).map_err(|e| self.invalid(format!("can't read the WebP: {e}")))?;
106        let encoded = encode(&img, format).map_err(|e| self.invalid(format!("can't write the {}: {e}", format.ext())))?;
107        let dir = item.info_dir(self.root());
108        let name = crate::disk_name::on_disk(&item.name);
109        let new = dir.join(&*crate::disk_name::on_disk(&format!("{name}.{}", format.ext())));
110        if new.exists() {
111            return Err(self.invalid(format!("{} already exists", new.display())));
112        }
113        let staged = dir.join(format!(".{name}.{}.falcon-convert", format.ext()));
114        let kept = dir.join(format!(".{name}.webp.falcon-old"));
115        let written = fs::File::create(&staged).and_then(|mut f| f.write_all(&encoded).and_then(|()| f.sync_all()));
116        if let Err(e) = written {
117            let _ = fs::remove_file(&staged);
118            return Err(Error::io(&staged, e));
119        }
120        if let Err(e) = fs::rename(&old, &kept) {
121            let _ = fs::remove_file(&staged);
122            return Err(Error::io(&old, e));
123        }
124        if let Err(e) = fs::rename(&staged, &new) {
125            let _ = fs::rename(&kept, &old);
126            let _ = fs::remove_file(&staged);
127            return Err(Error::io(&new, e));
128        }
129        let thumb = item.thumbnail_path(self.root());
130        let small = img.width() <= NO_THUMBNAIL_SIZE && img.height() <= NO_THUMBNAIL_SIZE;
131        item.ext = format.ext().to_string();
132        item.size = encoded.len() as u64;
133        if small {
134            item.no_thumbnail = Some(true);
135            item.remove_thumbnail = None;
136        }
137        if let Err(e) = self.update_item(item) {
138            let _ = fs::remove_file(&new);
139            let _ = fs::rename(&kept, &old);
140            return Err(e);
141        }
142        if let Some(j) = &mut self.journal {
143            j.irreversible = true;
144        }
145        // Eagle drops a small image's thumbnail; the original stands in.
146        if small && let Some(t) = thumb.filter(|t| t.file_name().is_some_and(|n| n.to_string_lossy().ends_with("_thumbnail.png"))) {
147            let _ = fs::remove_file(t);
148        }
149        Ok(match discard(&kept) {
150            Ok(()) => Converted::Done,
151            Err(_) => Converted::KeptOld(kept),
152        })
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::import::ImportOptions;
160
161    fn webp(dir: &Path, name: &str, w: u32, h: u32, alpha: u8) -> PathBuf {
162        let img = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 10, 10, alpha]));
163        let enc = webp::Encoder::from_rgba(img.as_raw(), w, h).encode_lossless();
164        let p = dir.join(name);
165        fs::write(&p, &*enc).unwrap();
166        p
167    }
168
169    fn setup(w: u32, h: u32, alpha: u8) -> (tempfile::TempDir, Library, String) {
170        let d = tempfile::tempdir().unwrap();
171        let mut lib = Library::create(d.path().join("L.library")).unwrap();
172        let src = webp(d.path(), "pic.webp", w, h, alpha);
173        let id = lib.import_file(&src, &ImportOptions::default()).unwrap();
174        (d, lib, id)
175    }
176
177    #[test]
178    fn converts_to_png_and_hands_over_the_old_file() {
179        let (_d, mut lib, id) = setup(40, 30, 255);
180        let mut handed = None;
181        let got = lib.convert_webp(&id, Format::Png, |p| {
182            handed = Some(p.to_path_buf());
183            fs::remove_file(p)
184        });
185        assert_eq!(got.unwrap(), Converted::Done);
186        let item = lib.item(&id).unwrap().clone();
187        assert_eq!(item.ext, "png");
188        let path = item.original_path(lib.root());
189        assert_eq!(item.size, fs::metadata(&path).unwrap().len());
190        assert_eq!(image::open(&path).unwrap().width(), 40);
191        assert_eq!(item.width, 40);
192        assert!(handed.unwrap().to_string_lossy().ends_with(".pic.webp.falcon-old"));
193        // Only the new original and metadata.json are left (small: no thumbnail).
194        let mut names: Vec<String> = fs::read_dir(item.info_dir(lib.root())).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
195        names.sort();
196        assert_eq!(names, ["metadata.json", "pic.png"]);
197        assert_eq!(item.no_thumbnail, Some(true));
198        // Saved to disk.
199        let reopened = Library::open(lib.root()).unwrap();
200        assert_eq!(reopened.item(&id).unwrap().ext, "png");
201    }
202
203    #[test]
204    fn jpg_lays_transparency_on_white() {
205        let (_d, mut lib, id) = setup(8, 8, 0);
206        lib.convert_webp(&id, Format::Jpg, |p| fs::remove_file(p)).unwrap();
207        let item = lib.item(&id).unwrap().clone();
208        assert_eq!(item.ext, "jpg");
209        let px = image::open(item.original_path(lib.root())).unwrap().to_rgb8().get_pixel(4, 4).0;
210        assert!(px.iter().all(|&c| c > 245), "{px:?}");
211    }
212
213    #[test]
214    fn a_failed_discard_keeps_the_old_file() {
215        let (_d, mut lib, id) = setup(8, 8, 255);
216        let got = lib.convert_webp(&id, Format::Png, |_| Err(std::io::Error::other("no Trash"))).unwrap();
217        let Converted::KeptOld(p) = got else { panic!("{got:?}") };
218        assert!(p.exists());
219    }
220
221    #[test]
222    fn a_large_image_keeps_its_thumbnail() {
223        let (_d, mut lib, id) = setup(1200, 20, 255);
224        let thumb = lib.item(&id).unwrap().thumbnail_path(lib.root()).unwrap();
225        assert!(thumb.exists());
226        lib.convert_webp(&id, Format::Png, |p| fs::remove_file(p)).unwrap();
227        assert!(thumb.exists());
228        assert_ne!(lib.item(&id).unwrap().no_thumbnail, Some(true));
229    }
230
231    #[test]
232    fn skips_animations_other_types_and_existing_names() {
233        let (d, mut lib, id) = setup(8, 8, 255);
234        let item = lib.item(&id).unwrap().clone();
235        fs::write(item.info_dir(lib.root()).join("pic.png"), b"x").unwrap();
236        assert!(lib.convert_webp(&id, Format::Png, |p| fs::remove_file(p)).is_err());
237        assert_eq!(lib.item(&id).unwrap().ext, "webp");
238        assert!(item.original_path(lib.root()).exists());
239        let txt = d.path().join("a.txt");
240        fs::write(&txt, b"hi").unwrap();
241        let tid = lib.import_file(&txt, &ImportOptions::default()).unwrap();
242        assert_eq!(lib.convert_webp(&tid, Format::Png, |p| fs::remove_file(p)).unwrap(), Converted::NotWebp);
243        assert!(is_animated(b"RIFF....WEBPVP8XANIM....ANMF"));
244        assert!(!is_animated(b"RIFF....WEBPVP8 "));
245    }
246
247    #[test]
248    fn the_undo_step_is_irreversible() {
249        let (_d, mut lib, id) = setup(8, 8, 255);
250        lib.begin_transaction("Convert");
251        lib.convert_webp(&id, Format::Png, |p| fs::remove_file(p)).unwrap();
252        assert!(lib.end_transaction().unwrap().irreversible);
253    }
254}