1use 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#[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
36pub fn can_rotate(ext: &str) -> bool {
38 matches!(ext.to_lowercase().as_str(), "jpg" | "jpeg" | "png" | "webp" | "bmp")
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Flip {
44 Horizontal,
46 Vertical,
48}
49
50pub fn can_flip(ext: &str) -> bool {
53 can_rotate(ext)
54}
55
56pub fn can_crop(ext: &str) -> bool {
58 matches!(ext.to_lowercase().as_str(), "jpg" | "jpeg" | "png" | "webp")
59}
60
61impl Library {
62 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 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 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 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 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 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#[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
221fn 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 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
250fn 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
276fn 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
296pub 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
314fn 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
330pub 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
346pub 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
359struct Segment {
362 marker: u8,
363 start: usize,
364 len: usize,
365}
366
367fn 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
396fn 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#[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
422fn 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
434fn 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
441pub 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
449pub 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 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 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 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 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 let turned = set_jpeg_orientation(&jpg, 6).unwrap();
632 assert_eq!(jpeg_orientation(&turned), Some(6));
633 let again = set_jpeg_orientation(&turned, 3).unwrap();
635 assert_eq!(again.len(), turned.len());
636 assert_eq!(jpeg_orientation(&again), Some(3));
637 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 assert!(added.ends_with(&jpg[2..]));
652 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 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 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 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!((©.name, ©.ext, ©.folders, ©.tags, ©.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 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}