Skip to main content

falcon_library/
transform.rs

1//! Rotating, flipping and cropping an item's original file, following
2//! Eagle's viewer.
3//!
4//! Eagle rewrites the original in place and keeps its name. A JPEG is turned
5//! losslessly by changing its EXIF orientation; PNG, WebP and BMP are drawn
6//! rotated and encoded again. Cropping re-encodes PNG, JPEG and WebP. After
7//! either, the item is refreshed like "Refresh Thumbnail": size and file
8//! times are read again, width and height measured, the thumbnail redrawn
9//! and the palette recomputed.
10//!
11//! Undo can't bring back overwritten pixels, so these edits mark the open
12//! undo transaction irreversible (as a permanent delete does). Eagle has no
13//! undo for them either.
14
15use std::fs;
16use std::io::Cursor;
17use std::path::Path;
18
19use image::{DynamicImage, ImageFormat};
20
21use crate::error::{Error, Result};
22use crate::import::ImportOptions;
23use crate::library::Library;
24use crate::thumbnail;
25
26/// A rectangle in the original image's displayed pixels (EXIF orientation
27/// applied), as Eagle's crop tool measures it.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct PixelRect {
30    pub x: u32,
31    pub y: u32,
32    pub width: u32,
33    pub height: u32,
34}
35
36/// Formats Eagle's rotate button works on.
37pub fn can_rotate(ext: &str) -> bool {
38    matches!(ext.to_lowercase().as_str(), "jpg" | "jpeg" | "png" | "webp" | "bmp")
39}
40
41/// A mirror direction for [`Library::flip_item`].
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Flip {
44    /// Left and right swap.
45    Horizontal,
46    /// Top and bottom swap.
47    Vertical,
48}
49
50/// Formats Eagle's flip button writes to disk (AVIF is accepted by Eagle
51/// but fails when written, and falcon can't encode it).
52pub fn can_flip(ext: &str) -> bool {
53    can_rotate(ext)
54}
55
56/// Formats Eagle's crop tool works on.
57pub fn can_crop(ext: &str) -> bool {
58    matches!(ext.to_lowercase().as_str(), "jpg" | "jpeg" | "png" | "webp")
59}
60
61impl Library {
62    /// Turns an item's original by `quarter_turns` × 90° clockwise (negative
63    /// turns go anticlockwise). JPEGs only get a new EXIF orientation, so
64    /// their pixels are untouched; PNG, WebP and BMP are re-encoded.
65    /// Animated PNG and WebP are refused, as Eagle refuses APNG.
66    pub fn rotate_item(&mut self, id: &str, quarter_turns: i32) -> Result<()> {
67        let item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
68        let ext = item.ext.to_lowercase();
69        if !can_rotate(&ext) {
70            return Err(self.invalid(format!("falcon can't rotate .{} files", item.ext)));
71        }
72        let turns = quarter_turns.rem_euclid(4) as u8;
73        if turns == 0 {
74            return Ok(());
75        }
76        let raw = item.original_path(self.root());
77        let data = fs::read(&raw).map_err(|e| Error::io(&raw, e))?;
78        let out = if ext == "jpg" || ext == "jpeg" {
79            let current = jpeg_orientation(&data).ok_or_else(|| self.invalid("not a JPEG file"))?;
80            set_jpeg_orientation(&data, turn_orientation(current, turns)).ok_or_else(|| self.invalid("cannot write the JPEG's orientation"))?
81        } else {
82            self.refuse_animated(&data)?;
83            let img = thumbnail::decode(&raw).map_err(|e| self.invalid(e.to_string()))?;
84            let img = match turns {
85                1 => img.rotate90(),
86                2 => img.rotate180(),
87                _ => img.rotate270(),
88            };
89            encode_like(&img, &ext, &data).map_err(|e| self.invalid(e))?
90        };
91        self.rewrite(item, &raw, &out)
92    }
93
94    /// Mirrors an item's original as shown (EXIF orientation applied). Like
95    /// rotating, a JPEG only gets a new EXIF orientation and keeps its
96    /// pixels; PNG, WebP and BMP are drawn mirrored and re-encoded. The
97    /// item is refreshed (size, dimensions, thumbnail, palette) and the
98    /// open undo transaction marked irreversible. Animated PNG and WebP
99    /// are refused.
100    pub fn flip_item(&mut self, id: &str, flip: Flip) -> Result<()> {
101        let item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
102        let ext = item.ext.to_lowercase();
103        if !can_flip(&ext) {
104            return Err(self.invalid(format!("falcon can't flip .{} files", item.ext)));
105        }
106        let raw = item.original_path(self.root());
107        let data = fs::read(&raw).map_err(|e| Error::io(&raw, e))?;
108        let out = if ext == "jpg" || ext == "jpeg" {
109            let current = jpeg_orientation(&data).ok_or_else(|| self.invalid("not a JPEG file"))?;
110            set_jpeg_orientation(&data, flip_orientation(current, flip)).ok_or_else(|| self.invalid("cannot write the JPEG's orientation"))?
111        } else {
112            self.refuse_animated(&data)?;
113            let img = thumbnail::decode(&raw).map_err(|e| self.invalid(e.to_string()))?;
114            let img = match flip {
115                Flip::Horizontal => img.fliph(),
116                Flip::Vertical => img.flipv(),
117            };
118            encode_like(&img, &ext, &data).map_err(|e| self.invalid(e))?
119        };
120        self.rewrite(item, &raw, &out)
121    }
122
123    /// Crops an item's original to `rect`, in displayed pixels (the part
124    /// outside the image is ignored), and saves it under the same name. A
125    /// JPEG is encoded at quality 100 without its EXIF block, as a canvas
126    /// export does, so its orientation is baked into the pixels.
127    pub fn crop_item(&mut self, id: &str, rect: PixelRect) -> Result<()> {
128        let item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
129        let raw = item.original_path(self.root());
130        let Some(out) = self.cropped(&item, rect)? else { return Ok(()) };
131        self.rewrite(item, &raw, &out)
132    }
133
134    /// Eagle's crop "Save As": the cropped picture becomes a new item and
135    /// the original is left alone. The new item has the original's name,
136    /// tags, folders, source URL and rating, and its date added is just
137    /// after the original's so the two sit together. Returns its id; a
138    /// crop of the whole picture still makes a copy.
139    pub fn crop_item_as_new(&mut self, id: &str, rect: PixelRect) -> Result<String> {
140        let item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
141        let out = match self.cropped(&item, rect)? {
142            Some(bytes) => bytes,
143            None => {
144                let raw = item.original_path(self.root());
145                fs::read(&raw).map_err(|e| Error::io(&raw, e))?
146            }
147        };
148        let dir = tempfile::Builder::new().prefix("falcon-crop").tempdir().map_err(|e| Error::io(self.root(), e))?;
149        let file = dir.path().join(&*crate::disk_name::on_disk(&format!("{}.{}", item.name, item.ext)));
150        fs::write(&file, &out).map_err(|e| Error::io(&file, e))?;
151        let opts = ImportOptions { folders: item.folders.clone(), tags: item.tags.clone(), url: item.url.clone(), ..Default::default() };
152        self.check_folders(&file, &opts)?;
153        let staged = crate::import::stage(&file, &opts)?;
154        let new_id = self.commit_import(staged)?;
155        let mut copy = self.item(&new_id).cloned().ok_or_else(|| self.invalid("the cropped copy went missing"))?;
156        copy.modification_time = item.modification_time + 1;
157        if let Some(star) = item.extra.get("star").filter(|v| v.as_u64().is_some_and(|n| n > 0)) {
158            copy.extra.insert("star".into(), star.clone());
159        }
160        self.update_item(copy)?;
161        Ok(new_id)
162    }
163
164    /// The item's original cropped to `rect` and encoded in its format, or
165    /// `None` when `rect` covers the whole picture.
166    fn cropped(&self, item: &crate::model::Item, rect: PixelRect) -> Result<Option<Vec<u8>>> {
167        let ext = item.ext.to_lowercase();
168        if !can_crop(&ext) {
169            return Err(self.invalid(format!("falcon can't crop .{} files", item.ext)));
170        }
171        let raw = item.original_path(self.root());
172        let data = fs::read(&raw).map_err(|e| Error::io(&raw, e))?;
173        self.refuse_animated(&data)?;
174        let img = thumbnail::decode(&raw).map_err(|e| self.invalid(e.to_string()))?;
175        let x = rect.x.min(img.width());
176        let y = rect.y.min(img.height());
177        let w = rect.width.min(img.width() - x);
178        let h = rect.height.min(img.height() - y);
179        if w == 0 || h == 0 {
180            return Err(self.invalid("the crop area is empty"));
181        }
182        if (x, y, w, h) == (0, 0, img.width(), img.height()) {
183            return Ok(None);
184        }
185        encode_like(&img.crop_imm(x, y, w, h), &ext, &data).map(Some).map_err(|e| self.invalid(e))
186    }
187
188    fn refuse_animated(&self, data: &[u8]) -> Result<()> {
189        if is_animated(data) { Err(self.invalid("animated images can't be edited")) } else { Ok(()) }
190    }
191
192    /// Writes the new bytes over the original, then refreshes the item and
193    /// marks the undo step irreversible. The file is replaced atomically,
194    /// so a failed write leaves the original as it was; its birth time stays,
195    /// as Eagle's in-place write keeps it.
196    fn rewrite(&mut self, item: crate::model::Item, raw: &Path, bytes: &[u8]) -> Result<()> {
197        replace_atomically(raw, bytes)?;
198        let r = self.redraw_item(item);
199        if let Some(j) = &mut self.journal {
200            j.irreversible = true;
201        }
202        r
203    }
204}
205
206/// Gives the file the birth time `born`, where the system keeps one to set.
207#[cfg(target_os = "macos")]
208fn keep_birth_time(f: &fs::File, born: Option<std::time::SystemTime>) -> std::io::Result<()> {
209    use std::os::macos::fs::FileTimesExt;
210    match born {
211        Some(t) => f.set_times(fs::FileTimes::new().set_created(t)),
212        None => Ok(()),
213    }
214}
215
216#[cfg(not(target_os = "macos"))]
217fn keep_birth_time(_: &fs::File, _: Option<std::time::SystemTime>) -> std::io::Result<()> {
218    Ok(())
219}
220
221/// Replaces `path` with `bytes` atomically: the bytes go into `path.staged`
222/// beside it and are renamed over the original once synced, so a crash
223/// leaves either the old file or the new one. A failure removes the staged
224/// file and leaves the original untouched.
225fn replace_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
226    use std::io::Write;
227    let mut name = path.as_os_str().to_owned();
228    name.push(".staged");
229    let staged = std::path::PathBuf::from(name);
230    // Copying first gives the staged file the original's permissions and
231    // extended attributes, which the rename then carries over.
232    let born = fs::metadata(path).and_then(|m| m.created()).ok();
233    fs::copy(path, &staged).map_err(|e| Error::io(&staged, e))?;
234    let done = fs::OpenOptions::new()
235        .write(true)
236        .truncate(true)
237        .open(&staged)
238        .and_then(|mut f| {
239            f.write_all(bytes)?;
240            keep_birth_time(&f, born)?;
241            f.sync_all()
242        })
243        .and_then(|()| fs::rename(&staged, path));
244    done.map_err(|e| {
245        let _ = fs::remove_file(&staged);
246        Error::io(path, e)
247    })
248}
249
250/// Encodes `img` in the item's format. JPEG uses quality 100 and BMP is
251/// plain. WebP stays lossless when the original was lossless and otherwise
252/// uses lossy quality 100.
253fn encode_like(img: &DynamicImage, ext: &str, original: &[u8]) -> std::result::Result<Vec<u8>, String> {
254    let mut out = Vec::new();
255    match ext {
256        "jpg" | "jpeg" => {
257            let rgb = img.to_rgb8();
258            image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 100).encode_image(&rgb).map_err(|e| e.to_string())?;
259        }
260        "png" => {
261            let img = if img.color().has_alpha() { img.clone() } else { DynamicImage::ImageRgb8(img.to_rgb8()) };
262            img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).map_err(|e| e.to_string())?;
263        }
264        "bmp" => img.write_to(&mut Cursor::new(&mut out), ImageFormat::Bmp).map_err(|e| e.to_string())?,
265        "webp" => {
266            let rgba = img.to_rgba8();
267            let enc = webp::Encoder::from_rgba(rgba.as_raw(), rgba.width(), rgba.height());
268            let mem = if webp_is_lossless(original) { enc.encode_lossless() } else { enc.encode(100.0) };
269            out = mem.to_vec();
270        }
271        _ => return Err(format!("cannot write .{ext} files")),
272    }
273    Ok(out)
274}
275
276/// RIFF chunks of a WebP file: (fourcc, payload).
277fn webp_chunks(data: &[u8]) -> Vec<(&[u8], &[u8])> {
278    let mut out = Vec::new();
279    if data.len() < 12 || &data[..4] != b"RIFF" || &data[8..12] != b"WEBP" {
280        return out;
281    }
282    let mut at = 12;
283    while at + 8 <= data.len() {
284        let len = u32::from_le_bytes([data[at + 4], data[at + 5], data[at + 6], data[at + 7]]) as usize;
285        let end = (at + 8 + len).min(data.len());
286        out.push((&data[at..at + 4], &data[at + 8..end]));
287        at = at + 8 + len + (len & 1);
288    }
289    out
290}
291
292fn webp_is_lossless(data: &[u8]) -> bool {
293    webp_chunks(data).iter().any(|(id, _)| *id == b"VP8L")
294}
295
296/// An animated PNG (an `acTL` chunk) or WebP (an `ANIM` chunk).
297pub fn is_animated(data: &[u8]) -> bool {
298    if data.starts_with(b"\x89PNG\r\n\x1a\n") {
299        let mut at = 8;
300        while at + 8 <= data.len() {
301            let len = u32::from_be_bytes([data[at], data[at + 1], data[at + 2], data[at + 3]]) as usize;
302            match &data[at + 4..at + 8] {
303                b"acTL" => return true,
304                b"IDAT" | b"IEND" => return false,
305                _ => {}
306            }
307            at += 12 + len;
308        }
309        return false;
310    }
311    webp_chunks(data).iter().any(|(id, _)| *id == b"ANIM")
312}
313
314/// EXIF orientation as (mirrored, clockwise quarter turns applied after the
315/// mirror). 1 is upright; 6, 3 and 8 turn it 90°, 180° and 270°; 2, 7, 4
316/// and 5 are the same turns of the mirrored image.
317fn orientation_parts(o: u16) -> (bool, u8) {
318    match o {
319        6 => (false, 1),
320        3 => (false, 2),
321        8 => (false, 3),
322        2 => (true, 0),
323        7 => (true, 1),
324        4 => (true, 2),
325        5 => (true, 3),
326        _ => (false, 0),
327    }
328}
329
330/// The orientation after turning an image shown with orientation `o` by
331/// `turns` more clockwise quarter turns.
332pub fn turn_orientation(o: u16, turns: u8) -> u16 {
333    let (mirrored, q) = orientation_parts(o);
334    match (mirrored, (q + turns) % 4) {
335        (false, 0) => 1,
336        (false, 1) => 6,
337        (false, 2) => 3,
338        (false, 3) => 8,
339        (true, 0) => 2,
340        (true, 1) => 7,
341        (true, 2) => 4,
342        _ => 5,
343    }
344}
345
346/// The orientation after mirroring an image shown with orientation `o`.
347/// A horizontal mirror of a turned image is the mirrored image turned the
348/// other way; a vertical mirror is a horizontal one plus a half turn.
349pub fn flip_orientation(o: u16, flip: Flip) -> u16 {
350    let (mirrored, q) = orientation_parts(o);
351    let back = (4 - q) % 4;
352    let base = if mirrored { turn_orientation(1, back) } else { turn_orientation(2, back) };
353    match flip {
354        Flip::Horizontal => base,
355        Flip::Vertical => turn_orientation(base, 2),
356    }
357}
358
359/// A JPEG segment: marker byte, offset of the marker and total length
360/// including the marker and length bytes.
361struct Segment {
362    marker: u8,
363    start: usize,
364    len: usize,
365}
366
367/// The segments before the image data, or `None` if `data` isn't a JPEG.
368fn jpeg_segments(data: &[u8]) -> Option<Vec<Segment>> {
369    if !data.starts_with(&[0xFF, 0xD8]) {
370        return None;
371    }
372    let mut out = Vec::new();
373    let mut at = 2;
374    while at + 4 <= data.len() {
375        if data[at] != 0xFF {
376            return None;
377        }
378        let marker = data[at + 1];
379        if marker == 0xFF {
380            at += 1;
381            continue;
382        }
383        if marker == 0xDA || marker == 0xD9 {
384            break;
385        }
386        let len = u16::from_be_bytes([data[at + 2], data[at + 3]]) as usize + 2;
387        if at + len > data.len() {
388            return None;
389        }
390        out.push(Segment { marker, start: at, len });
391        at += len;
392    }
393    Some(out)
394}
395
396/// The `Exif\0\0` APP1 segment, if any.
397fn exif_segment(data: &[u8], segs: &[Segment]) -> Option<(usize, usize)> {
398    segs.iter().find(|s| s.marker == 0xE1 && data[s.start + 4..s.start + s.len].starts_with(b"Exif\0\0")).map(|s| (s.start, s.len))
399}
400
401/// Byte order of a TIFF block.
402#[derive(Clone, Copy)]
403struct Tiff(bool);
404
405impl Tiff {
406    fn u16(self, b: &[u8], at: usize) -> Option<u16> {
407        let v = [*b.get(at)?, *b.get(at + 1)?];
408        Some(if self.0 { u16::from_le_bytes(v) } else { u16::from_be_bytes(v) })
409    }
410    fn u32(self, b: &[u8], at: usize) -> Option<u32> {
411        let v = [*b.get(at)?, *b.get(at + 1)?, *b.get(at + 2)?, *b.get(at + 3)?];
412        Some(if self.0 { u32::from_le_bytes(v) } else { u32::from_be_bytes(v) })
413    }
414    fn put16(self, v: u16) -> [u8; 2] {
415        if self.0 { v.to_le_bytes() } else { v.to_be_bytes() }
416    }
417    fn put32(self, v: u32) -> [u8; 4] {
418        if self.0 { v.to_le_bytes() } else { v.to_be_bytes() }
419    }
420}
421
422/// Reads a TIFF header: byte order and IFD0 offset.
423fn tiff_header(tiff: &[u8]) -> Option<(Tiff, usize)> {
424    let order = match tiff.get(..2)? {
425        b"II" => Tiff(true),
426        b"MM" => Tiff(false),
427        _ => return None,
428    };
429    Some((order, order.u32(tiff, 4)? as usize))
430}
431
432const ORIENTATION_TAG: u16 = 0x0112;
433
434/// Offset (within the TIFF block) of IFD0's orientation entry.
435fn orientation_entry(tiff: &[u8]) -> Option<(Tiff, usize)> {
436    let (order, ifd) = tiff_header(tiff)?;
437    let n = order.u16(tiff, ifd)? as usize;
438    (0..n).map(|i| ifd + 2 + i * 12).find(|&e| order.u16(tiff, e) == Some(ORIENTATION_TAG)).map(|e| (order, e))
439}
440
441/// A JPEG's EXIF orientation: 1 when it has none, `None` if it isn't a JPEG.
442pub fn jpeg_orientation(data: &[u8]) -> Option<u16> {
443    let segs = jpeg_segments(data)?;
444    let Some((start, len)) = exif_segment(data, &segs) else { return Some(1) };
445    let tiff = &data[start + 10..start + len];
446    Some(orientation_entry(tiff).and_then(|(order, e)| order.u16(tiff, e + 8)).filter(|o| (1..=8).contains(o)).unwrap_or(1))
447}
448
449/// Returns `data` with its EXIF orientation set to `o`; the image data is
450/// copied unchanged. An existing tag is overwritten in place. If the EXIF
451/// block has no orientation, a copy of IFD0 with the tag added is appended
452/// to the block and the header pointed at it (offsets elsewhere stay valid).
453/// A JPEG with no EXIF gets a minimal block after its JFIF header.
454pub fn set_jpeg_orientation(data: &[u8], o: u16) -> Option<Vec<u8>> {
455    let segs = jpeg_segments(data)?;
456    let Some((start, len)) = exif_segment(data, &segs) else {
457        let mut seg = vec![0xFF, 0xE1, 0, 0];
458        seg.extend_from_slice(b"Exif\0\0MM\0\x2a\0\0\0\x08\0\x01");
459        seg.extend_from_slice(&ORIENTATION_TAG.to_be_bytes());
460        seg.extend_from_slice(&[0, 3, 0, 0, 0, 1]);
461        seg.extend_from_slice(&o.to_be_bytes());
462        seg.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
463        let n = (seg.len() - 2) as u16;
464        seg[2..4].copy_from_slice(&n.to_be_bytes());
465        let at = segs.first().filter(|s| s.marker == 0xE0).map_or(2, |s| s.start + s.len);
466        return Some([&data[..at], &seg, &data[at..]].concat());
467    };
468    let tiff_at = start + 10;
469    let tiff = &data[tiff_at..start + len];
470    if let Some((order, e)) = orientation_entry(tiff) {
471        let mut out = data.to_vec();
472        let at = tiff_at + e;
473        out[at + 2..at + 4].copy_from_slice(&order.put16(3));
474        out[at + 4..at + 8].copy_from_slice(&order.put32(1));
475        out[at + 8..at + 12].copy_from_slice(&[0; 4]);
476        out[at + 8..at + 10].copy_from_slice(&order.put16(o));
477        return Some(out);
478    }
479    let (order, ifd) = tiff_header(tiff)?;
480    let n = order.u16(tiff, ifd)? as usize;
481    let entries = tiff.get(ifd + 2..ifd + 2 + n * 12)?;
482    let next = tiff.get(ifd + 2 + n * 12..ifd + 6 + n * 12)?;
483    let mut new_entry = Vec::with_capacity(12);
484    new_entry.extend_from_slice(&order.put16(ORIENTATION_TAG));
485    new_entry.extend_from_slice(&order.put16(3));
486    new_entry.extend_from_slice(&order.put32(1));
487    new_entry.extend_from_slice(&order.put16(o));
488    new_entry.extend_from_slice(&[0, 0]);
489    // Entries stay sorted by tag, as TIFF asks.
490    let pos = (0..n).find(|&i| order.u16(entries, i * 12).is_some_and(|t| t > ORIENTATION_TAG)).unwrap_or(n);
491    let mut block = tiff.to_vec();
492    if block.len() % 2 == 1 {
493        block.push(0);
494    }
495    let new_ifd = block.len();
496    block.extend_from_slice(&order.put16(n as u16 + 1));
497    block.extend_from_slice(&entries[..pos * 12]);
498    block.extend_from_slice(&new_entry);
499    block.extend_from_slice(&entries[pos * 12..]);
500    block.extend_from_slice(next);
501    block[4..8].copy_from_slice(&order.put32(new_ifd as u32));
502    let seg_len = 2 + 6 + block.len();
503    if seg_len > 0xFFFF {
504        return None;
505    }
506    let mut seg = vec![0xFF, 0xE1];
507    seg.extend_from_slice(&(seg_len as u16).to_be_bytes());
508    seg.extend_from_slice(b"Exif\0\0");
509    seg.extend_from_slice(&block);
510    Some([&data[..start], &seg, &data[start + len..]].concat())
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::import::ImportOptions;
517    use image::{Rgb, RgbImage, Rgba, RgbaImage};
518
519    /// A noisy picture, so the encoded file is large and colourful.
520    fn picture(w: u32, h: u32) -> RgbImage {
521        let mut rng = fastrand::Rng::with_seed(7);
522        RgbImage::from_fn(w, h, |x, y| {
523            let n = rng.u8(..40);
524            Rgb([((x * 255) / w) as u8 ^ n, ((y * 255) / h) as u8, if x < w / 2 { 200 } else { n }])
525        })
526    }
527
528    fn encode(img: &RgbImage, fmt: ImageFormat) -> Vec<u8> {
529        let mut out = Vec::new();
530        img.write_to(&mut Cursor::new(&mut out), fmt).unwrap();
531        out
532    }
533
534    fn library_with(file: &str, bytes: &[u8]) -> (tempfile::TempDir, Library, String) {
535        let tmp = tempfile::tempdir().unwrap();
536        let mut lib = Library::create(tmp.path().join("T.library")).unwrap();
537        let src = tmp.path().join(file);
538        fs::write(&src, bytes).unwrap();
539        let id = lib.import_file(&src, &ImportOptions::default()).unwrap();
540        (tmp, lib, id)
541    }
542
543    fn keys(lib: &Library, id: &str) -> Vec<String> {
544        let item = lib.item(id).unwrap();
545        let text = fs::read_to_string(item.info_dir(lib.root()).join("metadata.json")).unwrap();
546        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
547        v.as_object().unwrap().keys().cloned().collect()
548    }
549
550    fn thumb_size(lib: &Library, id: &str) -> Option<(u32, u32)> {
551        let item = lib.item(id).unwrap();
552        item.thumbnail_path(lib.root()).map(|p| thumbnail::decode(&p).unwrap()).map(|i| (i.width(), i.height()))
553    }
554
555    #[test]
556    fn orientation_turns() {
557        assert_eq!(turn_orientation(1, 1), 6);
558        assert_eq!(turn_orientation(1, 3), 8);
559        assert_eq!(turn_orientation(6, 3), 1);
560        assert_eq!(turn_orientation(3, 2), 1);
561        assert_eq!(turn_orientation(2, 1), 7);
562        assert_eq!(turn_orientation(4, 1), 5);
563        assert_eq!(turn_orientation(5, 1), 2);
564        assert_eq!(turn_orientation(5, 3), 4);
565        for o in 1..=8 {
566            assert_eq!(turn_orientation(turn_orientation(o, 1), 3), o);
567        }
568    }
569
570    #[test]
571    fn flip_orientations_match_eagles_tables() {
572        // Eagle's lookup tables for orientations 1 to 8.
573        let horizontal = [2, 1, 4, 3, 6, 5, 8, 7];
574        let vertical = [4, 3, 2, 1, 8, 7, 6, 5];
575        for o in 1..=8u16 {
576            assert_eq!(flip_orientation(o, Flip::Horizontal), horizontal[o as usize - 1], "H {o}");
577            assert_eq!(flip_orientation(o, Flip::Vertical), vertical[o as usize - 1], "V {o}");
578        }
579    }
580
581    #[test]
582    fn flipping_a_png_mirrors_its_pixels() {
583        let src = picture(600, 400);
584        let png = encode(&src, ImageFormat::Png);
585        let (_tmp, mut lib, id) = library_with("wide.png", &png);
586        let before = lib.item(&id).unwrap().clone();
587        let order = keys(&lib, &id);
588        lib.begin_transaction("Flip");
589        lib.flip_item(&id, Flip::Horizontal).unwrap();
590        assert!(lib.end_transaction().unwrap().irreversible);
591        let after = lib.item(&id).unwrap().clone();
592        assert_eq!((after.width, after.height), (600, 400));
593        assert!(after.last_modified > before.last_modified && !after.palettes.is_empty());
594        assert_eq!(keys(&lib, &id), order);
595        let img = thumbnail::decode(&after.original_path(lib.root())).unwrap().to_rgb8();
596        assert_eq!(img.get_pixel(0, 0), src.get_pixel(599, 0));
597        assert_eq!(img.get_pixel(10, 300), src.get_pixel(589, 300));
598        lib.flip_item(&id, Flip::Vertical).unwrap();
599        let img = thumbnail::decode(&after.original_path(lib.root())).unwrap().to_rgb8();
600        assert_eq!(img.get_pixel(0, 0), src.get_pixel(599, 399));
601    }
602
603    #[test]
604    fn flipping_a_jpeg_only_changes_its_orientation() {
605        let src = picture(300, 200);
606        let jpg = encode(&src, ImageFormat::Jpeg);
607        let (_tmp, mut lib, id) = library_with("photo.jpg", &jpg);
608        lib.flip_item(&id, Flip::Horizontal).unwrap();
609        let item = lib.item(&id).unwrap().clone();
610        let data = fs::read(item.original_path(lib.root())).unwrap();
611        assert_eq!(jpeg_orientation(&data), Some(2));
612        let pixels = |b: &[u8]| image::load_from_memory(b).unwrap().to_rgb8();
613        assert!(pixels(&data).into_raw() == pixels(&jpg).into_raw(), "the compressed image is untouched");
614        // Shown mirrored: the left edge now holds what was on the right.
615        let shown = thumbnail::decode(&item.original_path(lib.root())).unwrap().to_rgb8();
616        let stored = pixels(&jpg);
617        assert_eq!(shown.get_pixel(0, 100), stored.get_pixel(299, 100));
618        assert_eq!((item.width, item.height), (300, 200));
619        lib.flip_item(&id, Flip::Vertical).unwrap();
620        assert_eq!(jpeg_orientation(&fs::read(item.original_path(lib.root())).unwrap()), Some(3));
621        let gif = encode(&picture(10, 10), ImageFormat::Gif);
622        let (_tmp, mut lib, id) = library_with("a.gif", &gif);
623        assert!(lib.flip_item(&id, Flip::Horizontal).is_err());
624    }
625
626    #[test]
627    fn jpeg_orientation_is_written_without_touching_pixels() {
628        let jpg = encode(&picture(40, 20), ImageFormat::Jpeg);
629        assert_eq!(jpeg_orientation(&jpg), Some(1));
630        // No EXIF yet: a block is added.
631        let turned = set_jpeg_orientation(&jpg, 6).unwrap();
632        assert_eq!(jpeg_orientation(&turned), Some(6));
633        // Existing tag: overwritten in place, same length.
634        let again = set_jpeg_orientation(&turned, 3).unwrap();
635        assert_eq!(again.len(), turned.len());
636        assert_eq!(jpeg_orientation(&again), Some(3));
637        // EXIF without an orientation tag (a single other tag): IFD0 is copied with it added.
638        let mut seg = vec![0xFF, 0xE1, 0, 0];
639        seg.extend_from_slice(b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0");
640        seg.extend_from_slice(&[0x31, 0x01, 2, 0, 4, 0, 0, 0, b'f', b'a', b'l', 0]);
641        seg.extend_from_slice(&[0, 0, 0, 0]);
642        let n = (seg.len() - 2) as u16;
643        seg[2..4].copy_from_slice(&n.to_be_bytes());
644        let with_exif = [&jpg[..2], &seg, &jpg[2..]].concat();
645        assert_eq!(jpeg_orientation(&with_exif), Some(1));
646        let added = set_jpeg_orientation(&with_exif, 8).unwrap();
647        assert_eq!(jpeg_orientation(&added), Some(8));
648        let exif = ::exif::Reader::new().read_from_container(&mut Cursor::new(&added)).unwrap();
649        assert!(exif.get_field(::exif::Tag::Software, ::exif::In::PRIMARY).is_some(), "other tags survive");
650        // The image data after the EXIF block is the same bytes.
651        assert!(added.ends_with(&jpg[2..]));
652        // Decoders see the turn.
653        let img = image::load_from_memory(&added).unwrap();
654        assert_eq!((img.width(), img.height()), (40, 20));
655        let path = tempfile::tempdir().unwrap();
656        let p = path.path().join("a.jpg");
657        fs::write(&p, &added).unwrap();
658        let shown = thumbnail::decode(&p).unwrap();
659        assert_eq!((shown.width(), shown.height()), (20, 40));
660    }
661
662    #[test]
663    fn animation_is_detected() {
664        let png = encode(&picture(4, 4), ImageFormat::Png);
665        assert!(!is_animated(&png));
666        let mut apng = png[..33].to_vec();
667        apng.extend_from_slice(&[0, 0, 0, 8]);
668        apng.extend_from_slice(b"acTL\0\0\0\x02\0\0\0\0\0\0\0\0");
669        apng.extend_from_slice(&png[33..]);
670        assert!(is_animated(&apng));
671    }
672
673    #[test]
674    fn rotating_a_png_redraws_everything() {
675        let png = encode(&picture(1200, 800), ImageFormat::Png);
676        let (_tmp, mut lib, id) = library_with("wide.png", &png);
677        let before = lib.item(&id).unwrap().clone();
678        assert_eq!((before.width, before.height), (1200, 800));
679        assert_eq!(thumb_size(&lib, &id), Some((480, 320)));
680        let order = keys(&lib, &id);
681        lib.rotate_item(&id, -1).unwrap();
682        let after = lib.item(&id).unwrap().clone();
683        assert_eq!((after.width, after.height), (800, 1200));
684        assert_eq!(thumb_size(&lib, &id), Some((320, 480)));
685        assert!(!after.palettes.is_empty());
686        assert_ne!(after.size, 0);
687        assert_eq!(after.modification_time, before.modification_time);
688        assert!(after.last_modified > before.last_modified);
689        assert_eq!(keys(&lib, &id), order);
690        // Anticlockwise: the original's top-right corner is now top-left.
691        let img = thumbnail::decode(&after.original_path(lib.root())).unwrap().to_rgb8();
692        let src = picture(1200, 800);
693        assert_eq!(img.get_pixel(0, 0), src.get_pixel(1199, 0));
694    }
695
696    #[test]
697    fn rotating_a_jpeg_only_changes_its_orientation() {
698        let jpg = encode(&picture(1000, 600), ImageFormat::Jpeg);
699        let (_tmp, mut lib, id) = library_with("photo.jpg", &jpg);
700        let order = keys(&lib, &id);
701        lib.rotate_item(&id, 1).unwrap();
702        let item = lib.item(&id).unwrap().clone();
703        assert_eq!((item.width, item.height), (600, 1000));
704        let data = fs::read(item.original_path(lib.root())).unwrap();
705        assert_eq!(jpeg_orientation(&data), Some(6));
706        let pixels = |b: &[u8]| image::load_from_memory(b).unwrap().to_rgb8().into_raw();
707        assert!(pixels(&data) == pixels(&jpg), "the compressed image is untouched");
708        assert_eq!(item.size, data.len() as u64);
709        assert_eq!(keys(&lib, &id), order);
710        lib.rotate_item(&id, 3).unwrap();
711        assert_eq!(jpeg_orientation(&fs::read(item.original_path(lib.root())).unwrap()), Some(1));
712        assert_eq!(lib.item(&id).unwrap().width, 1000);
713    }
714
715    #[test]
716    fn cropping_a_jpeg_and_a_png() {
717        let jpg = encode(&picture(1600, 1000), ImageFormat::Jpeg);
718        let (_tmp, mut lib, id) = library_with("big.jpg", &jpg);
719        assert_eq!(thumb_size(&lib, &id), Some((512, 320)));
720        let order = keys(&lib, &id);
721        lib.crop_item(&id, PixelRect { x: 100, y: 100, width: 1200, height: 600 }).unwrap();
722        let item = lib.item(&id).unwrap().clone();
723        assert_eq!((item.width, item.height), (1200, 600));
724        assert_eq!(thumb_size(&lib, &id), Some((640, 320)));
725        assert!(!item.palettes.is_empty());
726        assert_eq!(keys(&lib, &id), order);
727        // A crop past the edge is clipped to the image.
728        lib.crop_item(&id, PixelRect { x: 1000, y: 0, width: 900, height: 900 }).unwrap();
729        assert_eq!((lib.item(&id).unwrap().width, lib.item(&id).unwrap().height), (200, 600));
730        assert!(lib.crop_item(&id, PixelRect { x: 5000, y: 0, width: 10, height: 10 }).is_err());
731
732        // A small PNG with transparency: no thumbnail before or after.
733        let rgba = RgbaImage::from_fn(300, 200, |x, _| Rgba([200, (x % 255) as u8, 30, if x < 150 { 255 } else { 0 }]));
734        let mut png = Vec::new();
735        rgba.write_to(&mut Cursor::new(&mut png), ImageFormat::Png).unwrap();
736        let (_tmp2, mut lib, id) = library_with("small.png", &png);
737        let order = keys(&lib, &id);
738        lib.crop_item(&id, PixelRect { x: 0, y: 0, width: 150, height: 100 }).unwrap();
739        let item = lib.item(&id).unwrap();
740        assert_eq!((item.width, item.height, item.no_thumbnail), (150, 100, Some(true)));
741        assert_eq!(thumb_size(&lib, &id), None);
742        assert_eq!(keys(&lib, &id), order);
743        let img = thumbnail::decode(&item.original_path(lib.root())).unwrap();
744        assert!(img.color().has_alpha());
745    }
746
747    #[test]
748    fn crop_save_as_adds_an_item_and_keeps_the_original() {
749        let jpg = encode(&picture(1600, 1000), ImageFormat::Jpeg);
750        let tmp = tempfile::tempdir().unwrap();
751        let mut lib = Library::create(tmp.path().join("T.library")).unwrap();
752        let folder = lib.create_folder(None, "F").unwrap();
753        let src = tmp.path().join("big.jpg");
754        fs::write(&src, &jpg).unwrap();
755        let opts = ImportOptions { folders: vec![folder.clone()], tags: vec!["t".into()], url: "https://example.com/x".into(), annotation: "note".into(), ..Default::default() };
756        let id = lib.import_file(&src, &opts).unwrap();
757        let mut rated = lib.item(&id).unwrap().clone();
758        rated.extra.insert("star".into(), 4.into());
759        lib.update_item(rated).unwrap();
760        let before = lib.item(&id).unwrap().clone();
761        let original_bytes = fs::read(before.original_path(lib.root())).unwrap();
762        let meta_before = fs::read(before.info_dir(lib.root()).join("metadata.json")).unwrap();
763
764        let new_id = lib.crop_item_as_new(&id, PixelRect { x: 100, y: 100, width: 800, height: 400 }).unwrap();
765        assert_ne!(new_id, id);
766        assert_eq!(fs::read(before.original_path(lib.root())).unwrap(), original_bytes, "the original file is untouched");
767        assert_eq!(fs::read(before.info_dir(lib.root()).join("metadata.json")).unwrap(), meta_before, "and so is its record");
768        let copy = lib.item(&new_id).unwrap().clone();
769        assert_eq!((copy.width, copy.height), (800, 400));
770        assert_eq!((&copy.name, &copy.ext, &copy.folders, &copy.tags, &copy.url), (&before.name, &before.ext, &before.folders, &before.tags, &before.url));
771        assert_eq!(copy.annotation, "", "Eagle doesn't carry the note over");
772        assert_eq!(copy.extra.get("star"), Some(&serde_json::Value::from(4)));
773        assert_eq!(copy.modification_time, before.modification_time + 1);
774        assert!(!copy.palettes.is_empty());
775        // Saved like any item, and found again on reopening.
776        let again = Library::open(lib.root()).unwrap();
777        assert_eq!(again.item(&new_id).unwrap().width, 800);
778    }
779
780    fn names_in(dir: &Path) -> Vec<String> {
781        fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect()
782    }
783
784    #[cfg(target_os = "macos")]
785    #[test]
786    fn replacing_keeps_the_birth_time() {
787        let dir = tempfile::tempdir().unwrap();
788        let p = dir.path().join("a.png");
789        fs::write(&p, b"old").unwrap();
790        let born = fs::metadata(&p).unwrap().created().unwrap();
791        std::thread::sleep(std::time::Duration::from_millis(50));
792        replace_atomically(&p, b"new").unwrap();
793        assert_eq!(fs::metadata(&p).unwrap().created().unwrap(), born);
794    }
795
796    #[test]
797    fn atomic_replace_swaps_the_contents() {
798        let tmp = tempfile::tempdir().unwrap();
799        let p = tmp.path().join("x.png");
800        fs::write(&p, b"old").unwrap();
801        replace_atomically(&p, b"brand new").unwrap();
802        assert_eq!(fs::read(&p).unwrap(), b"brand new");
803        assert_eq!(names_in(tmp.path()), vec!["x.png".to_string()]);
804    }
805
806    #[test]
807    fn atomic_replace_renames_instead_of_truncating() {
808        use std::io::Read;
809        let tmp = tempfile::tempdir().unwrap();
810        let p = tmp.path().join("x.png");
811        fs::write(&p, b"old bytes").unwrap();
812        let mut held = fs::File::open(&p).unwrap();
813        replace_atomically(&p, b"new").unwrap();
814        let mut seen = Vec::new();
815        held.read_to_end(&mut seen).unwrap();
816        assert_eq!(seen, b"old bytes");
817        assert_eq!(fs::read(&p).unwrap(), b"new");
818    }
819
820    #[cfg(unix)]
821    #[test]
822    fn atomic_replace_keeps_permissions() {
823        use std::os::unix::fs::PermissionsExt;
824        let tmp = tempfile::tempdir().unwrap();
825        let p = tmp.path().join("x.png");
826        fs::write(&p, b"old").unwrap();
827        fs::set_permissions(&p, fs::Permissions::from_mode(0o640)).unwrap();
828        replace_atomically(&p, b"new").unwrap();
829        assert_eq!(fs::metadata(&p).unwrap().permissions().mode() & 0o777, 0o640);
830    }
831
832    #[test]
833    fn atomic_replace_of_a_missing_file_fails_cleanly() {
834        let tmp = tempfile::tempdir().unwrap();
835        let p = tmp.path().join("gone.png");
836        assert!(replace_atomically(&p, b"new").is_err());
837        assert!(names_in(tmp.path()).is_empty());
838    }
839
840    #[test]
841    fn rewriting_leaves_no_backup_behind() {
842        let png = encode(&picture(300, 200), ImageFormat::Png);
843        let (_tmp, mut lib, id) = library_with("a.png", &png);
844        lib.crop_item(&id, PixelRect { x: 0, y: 0, width: 100, height: 100 }).unwrap();
845        let dir = lib.item(&id).unwrap().info_dir(lib.root());
846        let names: Vec<String> = fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
847        assert!(names.iter().all(|n| !n.ends_with(".bk") && !n.ends_with(".staged")), "{names:?}");
848    }
849
850    #[test]
851    fn cropping_to_a_small_image_drops_the_thumbnail_file() {
852        let jpg = encode(&picture(1600, 1000), ImageFormat::Jpeg);
853        let (_tmp, mut lib, id) = library_with("big.jpg", &jpg);
854        assert!(thumb_size(&lib, &id).is_some());
855        lib.crop_item(&id, PixelRect { x: 0, y: 0, width: 400, height: 300 }).unwrap();
856        let item = lib.item(&id).unwrap();
857        assert_eq!(item.no_thumbnail, Some(true));
858        assert_eq!(thumb_size(&lib, &id), None, "a stale thumbnail would be shown instead of the original");
859    }
860
861    #[test]
862    fn edits_are_irreversible_and_unsupported_formats_refused() {
863        let png = encode(&picture(100, 100), ImageFormat::Png);
864        let (_tmp, mut lib, id) = library_with("a.png", &png);
865        lib.begin_transaction("Rotate");
866        lib.rotate_item(&id, 1).unwrap();
867        assert!(lib.end_transaction().unwrap().irreversible);
868        let gif = encode(&picture(10, 10), ImageFormat::Gif);
869        let (_tmp, mut lib, id) = library_with("a.gif", &gif);
870        assert!(lib.rotate_item(&id, 1).is_err());
871        assert!(lib.crop_item(&id, PixelRect { x: 0, y: 0, width: 5, height: 5 }).is_err());
872    }
873}