1use std::fs;
4use std::io::Read;
5use std::path::Path;
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use filetime::FileTime;
9use image::DynamicImage;
10use serde_json::{Value, json};
11use unicode_normalization::UnicodeNormalization;
12
13use crate::error::{Error, Result};
14use crate::library::Library;
15use crate::model::Item;
16use crate::{exif, fontmeta, fontpreview, ids, keywords, media, palette, tempo, thumbnail, waveform, weblink};
17
18const MAX_FILE_NAME: usize = 255;
20const THUMB_SUFFIX: &str = "_thumbnail.png";
22
23#[derive(Debug, Clone)]
25pub struct ImportOptions {
26 pub folders: Vec<String>,
28 pub tags: Vec<String>,
30 pub palette: bool,
32 pub annotation: String,
34 pub url: String,
36 pub font_weight_tag: bool,
39 pub camera_metadata_all_photos: bool,
42 pub keywords_as_tags: bool,
45 pub folder_auto_tags: bool,
50 pub name: Option<String>,
53 pub trashed: bool,
55 pub added_at: Option<u64>,
58}
59
60impl Default for ImportOptions {
61 fn default() -> Self {
62 ImportOptions {
63 folders: Vec::new(),
64 tags: Vec::new(),
65 palette: true,
66 annotation: String::new(),
67 url: String::new(),
68 font_weight_tag: true,
69 camera_metadata_all_photos: false,
70 keywords_as_tags: false,
71 folder_auto_tags: false,
72 name: None,
73 trashed: false,
74 added_at: None,
75 }
76 }
77}
78
79const NOT_A_FILE: &str = "not a regular file";
81const EMPTY_FILE: &str = "file is empty";
82const UNRECOGNIZED: &str = "file format is not recognised";
83
84const PARTIAL_DOWNLOADS: [&str; 3] = ["crdownload", "part", "download"];
87
88pub fn is_recognized(ext: &str) -> bool {
94 !ext.is_empty() && !PARTIAL_DOWNLOADS.contains(&ext)
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum FailKind {
100 Missing,
102 NotAFile,
104 Empty,
106 Unrecognized,
108 Unreadable,
110 NoSpace,
112 CopyFailed,
114 LibraryWrite,
116 UnknownFolder,
118 Other,
120}
121
122impl FailKind {
123 pub fn describe(self) -> &'static str {
125 match self {
126 FailKind::Missing => "The file no longer exists",
127 FailKind::NotAFile => "Folders and packages can't be imported as items",
128 FailKind::Empty => "The file is empty",
129 FailKind::Unrecognized => "The file format isn't recognised",
130 FailKind::Unreadable => "The file can't be read",
131 FailKind::NoSpace => "Not enough disk space",
132 FailKind::CopyFailed => "Copying the file failed",
133 FailKind::LibraryWrite => "Writing to the library failed",
134 FailKind::UnknownFolder => "The folder to import into no longer exists",
135 FailKind::Other => "The file couldn't be imported",
136 }
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Failure {
143 pub path: std::path::PathBuf,
145 pub kind: FailKind,
147 pub detail: String,
149}
150
151impl Failure {
152 pub fn new(path: &Path, err: &Error) -> Failure {
155 use std::io::ErrorKind as K;
156 let kind = match err {
157 Error::Import { reason, .. } if reason == NOT_A_FILE => FailKind::NotAFile,
158 Error::Import { reason, .. } if reason == EMPTY_FILE => FailKind::Empty,
159 Error::Import { reason, .. } if reason == UNRECOGNIZED => FailKind::Unrecognized,
160 Error::Import { reason, .. } if reason.starts_with("unknown folder") => FailKind::UnknownFolder,
161 Error::Io { source, .. } if source.kind() == K::StorageFull => FailKind::NoSpace,
162 Error::Io { path: at, source } if at == path => match source.kind() {
163 K::NotFound => FailKind::Missing,
164 _ => FailKind::Unreadable,
165 },
166 Error::Io { path: at, source } if is_staging(at) => match source.kind() {
168 K::NotFound if !path.exists() => FailKind::Missing,
169 K::PermissionDenied => FailKind::Unreadable,
170 _ if cfg!(windows) && matches!(source.raw_os_error(), Some(32 | 33)) => FailKind::Unreadable,
172 _ => FailKind::CopyFailed,
173 },
174 Error::Io { .. } | Error::Json { .. } => FailKind::LibraryWrite,
175 _ => FailKind::Other,
176 };
177 Failure { path: path.to_path_buf(), kind, detail: err.to_string() }
178 }
179}
180
181fn is_staging(path: &Path) -> bool {
183 path.components().any(|c| c.as_os_str().to_string_lossy().starts_with(STAGING_PREFIX))
184}
185
186const STAGING_PREFIX: &str = "falcon-import";
187
188#[derive(Debug)]
192pub struct Staged {
193 item: Item,
194 dir: tempfile::TempDir,
195 auto_tags: bool,
197 added_at: Option<u64>,
199}
200
201impl Staged {
202 pub fn item(&self) -> &Item {
204 &self.item
205 }
206
207 pub fn original(&self) -> std::path::PathBuf {
209 let item = &self.item;
210 self.dir.path().join(if item.ext.is_empty() { item.name.clone() } else { format!("{}.{}", item.name, item.ext) })
211 }
212}
213
214pub fn stage(path: impl AsRef<Path>, opts: &ImportOptions) -> Result<Staged> {
217 let src = path.as_ref();
218 let refuse = |reason: &str| Error::Import { path: src.to_path_buf(), reason: reason.into() };
219
220 let meta = fs::metadata(src).map_err(|e| Error::io(src, e))?;
221 if !meta.is_file() {
222 return Err(refuse(NOT_A_FILE));
223 }
224 let ext = src.extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default();
225 if !is_recognized(&ext) {
226 return Err(refuse(UNRECOGNIZED));
227 }
228 if meta.len() == 0 && ext != "txt" {
229 return Err(refuse(EMPTY_FILE));
230 }
231
232 let mtime_ms = to_ms(meta.modified().ok());
233 let btime_ms = meta.created().ok().map_or(mtime_ms, |t| to_ms(Some(t)));
235 let stem = src.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
236 let name = match opts.name.as_deref().map(|n| sanitize_name(n, &ext)) {
237 Some(n) if !n.is_empty() => n,
238 _ => sanitize_name(&stem, &ext),
239 };
240
241 let mut item = Item {
242 id: String::new(),
243 name,
244 size: meta.len(),
245 btime: btime_ms,
246 mtime: mtime_ms,
247 ext,
248 tags: opts.tags.clone(),
249 folders: opts.folders.clone(),
250 is_deleted: opts.trashed,
251 url: opts.url.clone(),
252 annotation: opts.annotation.clone(),
253 modification_time: 0,
254 remove_thumbnail: None,
255 no_thumbnail: None,
256 height: 0,
257 width: 0,
258 last_modified: 0,
259 palettes: Vec::new(),
260 key_order: Default::default(),
261 extra: Default::default(),
262 };
263
264 if opts.keywords_as_tags {
265 add_keyword_tags(&mut item.tags, keywords::read(src, &item.ext));
266 }
267
268 let dir = tempfile::Builder::new().prefix(STAGING_PREFIX).tempdir().map_err(|e| Error::io(src, e))?;
269 let file = if item.ext.is_empty() { item.name.clone() } else { format!("{}.{}", item.name, item.ext) };
270 let raw = dir.path().join(file);
271 fs::copy(src, &raw).map_err(|e| Error::io(&raw, e))?;
272 crate::disk_name::make_writable(&raw).map_err(|e| Error::io(&raw, e))?;
273 filetime::set_file_times(&raw, FileTime::from_last_access_time(&meta), FileTime::from_last_modification_time(&meta))
274 .map_err(|e| Error::io(&raw, e))?;
275
276 let thumb = dir.path().join(format!("{}{THUMB_SUFFIX}", item.name));
277 let analysis = generate(&mut item, &raw, &thumb);
278 file_metadata(&mut item, &raw, opts);
279 if opts.palette
280 && let Some(img) = analysis.and_then(|a| match a {
281 Analysis::Original => thumbnail::decode(&raw).ok(),
282 Analysis::Thumbnail => thumbnail::decode(&thumb).ok(),
283 })
284 {
285 item.palettes = palette::extract(&img);
286 }
287 if item.remove_thumbnail == Some(true) {
288 let _ = fs::remove_file(&thumb);
289 }
290 if item.ext == "txt" {
291 set_text(&mut item, &raw);
292 item.key_order = crate::model::KeyOrder(crate::text::RECORD_KEYS.iter().map(|k| k.to_string()).collect());
293 }
294 Ok(Staged { item, dir, auto_tags: opts.folder_auto_tags, added_at: opts.added_at })
295}
296
297impl Library {
298 pub fn import_file(&mut self, path: impl AsRef<Path>, opts: &ImportOptions) -> Result<String> {
305 self.check_folders(path.as_ref(), opts)?;
306 let staged = stage(path, opts)?;
307 self.commit_import(staged)
308 }
309
310 pub fn check_folders(&self, path: &Path, opts: &ImportOptions) -> Result<()> {
312 let known = self.folder_ids();
313 match opts.folders.iter().find(|f| !known.contains(f.as_str())) {
314 Some(bad) => Err(Error::Import { path: path.to_path_buf(), reason: format!("unknown folder {bad}") }),
315 None => Ok(()),
316 }
317 }
318
319 pub fn commit_import(&mut self, staged: Staged) -> Result<String> {
322 let Staged { mut item, dir, auto_tags, added_at } = staged;
323 let known = self.folder_ids();
324 item.folders.retain(|f| known.contains(f.as_str()));
325 if auto_tags {
326 crate::autotag::append_new(&mut item.tags, &crate::autotag::auto_tags_for(&self.metadata.folders, &item.folders));
327 }
328 let mut id = ids::new_id();
329 while self.root().join("images").join(format!("{id}.info")).exists() {
330 id = ids::new_id();
331 }
332 item.id = id;
333 let dest = item.info_dir(self.root());
334 fs::create_dir_all(&dest).map_err(|e| Error::io(&dest, e))?;
335 let moved = (|| -> Result<()> {
336 for entry in fs::read_dir(dir.path()).map_err(|e| Error::io(dir.path(), e))? {
337 let entry = entry.map_err(|e| Error::io(dir.path(), e))?;
338 let to = dest.join(entry.file_name());
339 if fs::rename(entry.path(), &to).is_err() {
341 fs::copy(entry.path(), &to).map_err(|e| Error::io(&to, e))?;
342 if let Ok(m) = fs::metadata(entry.path()) {
343 let _ = filetime::set_file_times(&to, FileTime::from_last_access_time(&m), FileTime::from_last_modification_time(&m));
344 }
345 }
346 }
347 item.modification_time = match added_at {
348 Some(t) => t,
349 None => self.next_import_time(now_ms()),
350 };
351 item.last_modified = now_ms().max(item.modification_time);
352 self.register_item(item.clone())
353 })();
354 match moved {
355 Ok(()) => Ok(item.id),
356 Err(e) => {
357 let _ = fs::remove_dir_all(&dest);
358 Err(e)
359 }
360 }
361 }
362}
363
364pub(crate) fn redraw(item: &mut Item, raw: &Path, thumb: &Path) -> bool {
373 if item.ext == "txt" {
375 return set_text(item, raw);
376 }
377 let tmp = thumb.with_file_name(".falcon-redraw_thumbnail.png");
378 let _ = fs::remove_file(&tmp);
379 let mut next = item.clone();
380 let Some(analysis) = generate(&mut next, raw, &tmp) else {
381 let _ = fs::remove_file(&tmp);
382 return false;
383 };
384 if media::is_audio(&next.ext) {
385 match tempo::bpm(raw, &next.ext) {
388 Some(bpm) => {
389 next.extra.insert("bpm".into(), tempo::json_value(bpm));
390 }
391 None => {
392 next.extra.shift_remove("bpm");
393 }
394 }
395 } else {
396 let img = match analysis {
397 Analysis::Original => thumbnail::decode(raw).ok(),
398 Analysis::Thumbnail => thumbnail::decode(&tmp).ok(),
399 };
400 next.palettes = img.map(|i| palette::extract(&i)).unwrap_or_default();
401 }
402 let keep = tmp.is_file() && next.remove_thumbnail != Some(true);
403 if keep {
404 if fs::rename(&tmp, thumb).is_err() {
405 let _ = fs::remove_file(&tmp);
406 return false;
407 }
408 } else {
409 let _ = fs::remove_file(&tmp);
410 let _ = fs::remove_file(thumb);
411 }
412 *item = next;
413 true
414}
415
416fn set_text(item: &mut Item, raw: &Path) -> bool {
420 match crate::text::preview(raw) {
421 Ok(text) => {
422 item.extra.insert(crate::text::TEXT_KEY.into(), text.into());
423 true
424 }
425 Err(_) => false,
426 }
427}
428
429enum Analysis {
431 Original,
432 Thumbnail,
433}
434
435fn generate(item: &mut Item, raw: &Path, thumb: &Path) -> Option<Analysis> {
439 let max = thumbnail::max_side(&item.ext);
440 let apply = |item: &mut Item, plan: thumbnail::Plan| {
441 item.no_thumbnail = plan.no_thumbnail;
442 item.remove_thumbnail = plan.remove_thumbnail;
443 };
444 if item.ext == "svg" {
445 let data = fs::read(raw).ok()?;
446 let rendered = thumbnail::svg_size(&data).and_then(|(w, h)| {
447 let plan = thumbnail::plan_svg(item.size);
448 let limit = (plan.remove_thumbnail != Some(true)).then_some(max);
450 let img = thumbnail::render_svg(&data, limit)?;
451 thumbnail::write_encoded(&img, thumb)?;
452 Ok((w, h, plan))
453 });
454 return match rendered {
455 Ok((w, h, plan)) => {
456 item.width = w.into();
457 item.height = h.into();
458 apply(item, plan);
459 Some(Analysis::Thumbnail)
460 }
461 Err(_) => {
462 apply(item, thumbnail::plan_svg_failed());
463 None
464 }
465 };
466 }
467 if let Some((img, w, h)) = drawn_thumbnail(&item.ext, raw) {
468 thumbnail::write_thumbnail(img, max, thumb).ok()?;
469 item.width = w.into();
470 item.height = h.into();
471 return Some(Analysis::Thumbnail);
472 }
473 if item.ext == "mhtml" {
474 return mhtml_thumbnail(item, raw, thumb, max);
475 }
476 if !thumbnail::is_supported(&item.ext) {
477 if media::is_video(&item.ext) && falcon_ffmpeg::handles(&item.ext) {
480 return ffmpeg_thumbnail(item, raw, thumb).or_else(|| quicklook_thumbnail(item, raw, thumb, max));
481 }
482 if media::is_video(&item.ext) {
483 return quicklook_thumbnail(item, raw, thumb, max).or_else(|| ffmpeg_thumbnail(item, raw, thumb));
484 }
485 return quicklook_thumbnail(item, raw, thumb, max);
486 }
487 let img = thumbnail::decode_for_display(raw).ok()?;
488 item.width = img.width().into();
489 item.height = img.height().into();
490 let plan = thumbnail::plan_raster(&item.ext, img.width(), img.height(), item.size);
491 if !plan.thumbnail {
492 apply(item, plan);
493 return Some(Analysis::Original);
494 }
495 if thumbnail::write_thumbnail(img, max, thumb).is_err() {
496 let _ = fs::remove_file(thumb);
497 return None;
498 }
499 apply(item, plan);
500 Some(Analysis::Thumbnail)
501}
502
503fn drawn_thumbnail(ext: &str, raw: &Path) -> Option<(DynamicImage, u32, u32)> {
507 if media::is_audio(ext) {
508 let img = waveform::thumbnail(raw, ext)?;
509 return Some((DynamicImage::ImageRgba8(img), waveform::WIDTH, waveform::HEIGHT));
510 }
511 if FONT_EXTENSIONS.contains(&ext) {
512 let data = fs::read(raw).ok()?;
513 let metas = fontmeta::font_metas(&data, "")?;
514 let img = fontpreview::render(&data, &metas)?;
515 return Some((img, fontpreview::SIZE, fontpreview::SIZE));
516 }
517 if crate::model3d::is_supported(ext) {
518 let img = crate::model3d::raster::thumbnail(raw, ext)?;
520 let (w, h) = img.dimensions();
521 return Some((DynamicImage::ImageRgba8(img), w, h));
522 }
523 None
524}
525
526fn quicklook_thumbnail(item: &mut Item, raw: &Path, thumb: &Path, max: u32) -> Option<Analysis> {
532 if matches!(item.ext.as_str(), "txt" | "url") {
536 return None;
537 }
538 if embedded_first(&item.ext)
541 && let Some(done) = embedded_thumbnail(item, raw, thumb, max)
542 {
543 return Some(done);
544 }
545 let Ok(img) = thumbnail::quicklook(raw, thumbnail::QUICKLOOK_SIZE) else {
546 return (!embedded_first(&item.ext)).then(|| embedded_thumbnail(item, raw, thumb, max)).flatten();
547 };
548 let meta = thumbnail::spotlight(raw);
549 let psd = crate::psd::handles(&item.ext).then(|| crate::psd::dimensions(raw)).flatten();
551 let camera = crate::exif::is_raw(&item.ext).then(|| fs::read(raw).ok()).flatten().map(|data| raw_dimensions(&data, raw, (img.width(), img.height())));
554 let (w, h) = psd.or(camera).or(meta.size).unwrap_or((img.width(), img.height()));
555 item.width = w.into();
556 item.height = h.into();
557 thumbnail::write_thumbnail(img, max, thumb).ok()?;
558 Some(Analysis::Thumbnail)
559}
560
561pub fn embedded_first(ext: &str) -> bool {
564 matches!(ext, "blend" | "dwg" | "skp" | "indd" | "indt" | "idml" | "sketch" | "xmind")
565}
566
567fn embedded_thumbnail(item: &mut Item, raw: &Path, thumb: &Path, max: u32) -> Option<Analysis> {
573 let (img, size) = if crate::exif::is_raw(&item.ext) {
574 let data = fs::read(raw).ok()?;
575 let img = thumbnail::raw_preview(&data).ok()?;
576 let size = raw_dimensions(&data, raw, (img.width(), img.height()));
577 (img, size)
578 } else if matches!(item.ext.as_str(), "ai" | "ait") && let Some(page) = pdf_page(raw) {
579 page
580 } else if crate::binary_preview::handles(&item.ext) {
581 let img = crate::binary_preview::preview(raw, &item.ext)?;
582 let size = (img.width(), img.height());
583 (img, size)
584 } else if crate::zip_preview::handles(&item.ext) {
585 let img = crate::zip_preview::preview(raw, &item.ext)?;
586 let size = (img.width(), img.height());
587 (img, size)
588 } else {
589 return None;
590 };
591 thumbnail::write_thumbnail(img, max, thumb).ok()?;
592 item.width = size.0.into();
593 item.height = size.1.into();
594 Some(Analysis::Thumbnail)
595}
596
597fn pdf_page(raw: &Path) -> Option<(DynamicImage, (u32, u32))> {
601 let mut head = Vec::new();
602 fs::File::open(raw).ok()?.take(1024).read_to_end(&mut head).ok()?;
603 if !head.windows(5).any(|w| w == b"%PDF-") {
604 return None;
605 }
606 let doc = falcon_pdf::Document::open(raw).ok()?;
607 let bitmap = doc.thumbnail(0, thumbnail::QUICKLOOK_SIZE as usize).ok()?;
608 let (pw, ph) = doc.page_size(0).unwrap_or((bitmap.width as f64, bitmap.height as f64));
609 let img = image::RgbaImage::from_raw(u32::try_from(bitmap.width).ok()?, u32::try_from(bitmap.height).ok()?, bitmap.rgba)?;
610 Some((DynamicImage::ImageRgba8(img), (pw.round().clamp(1.0, 1e6) as u32, ph.round().clamp(1.0, 1e6) as u32)))
611}
612
613fn raw_dimensions(data: &[u8], raw: &Path, fallback: (u32, u32)) -> (u32, u32) {
618 let area = |s: (u32, u32)| u64::from(s.0) * u64::from(s.1);
619 let preview = area(fallback);
620 let long = fallback.0.max(fallback.1);
622 let stated = crate::exif::stated_sizes(data).into_iter().filter(|&(w, h)| w.max(h) >= long && area((w, h)) >= preview).max_by_key(|&s| area(s));
623 if let Some((w, h)) = stated {
624 let turned = crate::exif::orientation(data).is_some_and(|o| (5..=8).contains(&o));
625 return if turned { (h, w) } else { (w, h) };
626 }
627 thumbnail::spotlight(raw).size.filter(|&s| s.0.max(s.1) >= long).unwrap_or(fallback)
628}
629
630#[cfg(test)]
631mod raw_size_tests {
632 use super::raw_dimensions;
633 use std::path::Path;
634
635 fn tiff_stating(w: u16, h: u16) -> Vec<u8> {
637 let mut t = vec![b'I', b'I', 42, 0, 8, 0, 0, 0, 2, 0];
638 for (tag, v) in [(0x0100u16, w), (0x0101, h)] {
639 t.extend_from_slice(&tag.to_le_bytes());
640 t.extend_from_slice(&3u16.to_le_bytes());
641 t.extend_from_slice(&1u32.to_le_bytes());
642 t.extend_from_slice(&v.to_le_bytes());
643 t.extend_from_slice(&[0, 0]);
644 }
645 t.extend_from_slice(&0u32.to_le_bytes());
646 t
647 }
648
649 #[test]
650 fn a_quicklook_render_does_not_decide_a_raw_photos_size() {
651 let data = tiff_stating(6000, 4000);
652 assert_eq!(raw_dimensions(&data, Path::new("/nonexistent/x.nef"), (1024, 683)), (6000, 4000));
653 assert_eq!(raw_dimensions(&[0u8; 16], Path::new("/nonexistent/x.nef"), (1024, 683)), (1024, 683));
655 }
656}
657
658pub const MHTML_SIZE: (u32, u32) = (1440, 900);
661
662fn mhtml_thumbnail(item: &mut Item, raw: &Path, thumb: &Path, max: u32) -> Option<Analysis> {
668 let dir = tempfile::Builder::new().prefix("falcon-mhtml-").tempdir().ok()?;
669 let unpacked = crate::mhtml::unpack_file(raw, dir.path(), crate::mhtml::Limits::default(), crate::mhtml::Policy::None).ok()?;
670 let img = thumbnail::quicklook(&unpacked.root, thumbnail::QUICKLOOK_SIZE).ok()?;
671 let (w, h) = (img.width(), img.height());
672 let (ew, eh) = MHTML_SIZE;
673 let cut = (u64::from(w) * u64::from(eh) / u64::from(ew)) as u32;
674 let img = if cut > 0 && cut < h { img.crop_imm(0, 0, w, cut) } else { img };
675 thumbnail::write_thumbnail(img, max, thumb).ok()?;
676 item.width = ew.into();
677 item.height = eh.into();
678 Some(Analysis::Thumbnail)
679}
680
681fn ffmpeg_thumbnail(item: &mut Item, raw: &Path, thumb: &Path) -> Option<Analysis> {
687 if !falcon_ffmpeg::available() {
688 return None;
689 }
690 let (info, frame, _) = falcon_ffmpeg::thumbnail(raw).ok()?;
691 let img = image::RgbaImage::from_raw(frame.width as u32, frame.height as u32, frame.rgba)?;
692 let bytes = thumbnail::video_thumbnail_jpeg(&img).ok()?;
693 fs::write(thumb, bytes).ok()?;
694 item.width = info.width.into();
695 item.height = info.height.into();
696 Some(Analysis::Thumbnail)
697}
698
699fn file_metadata(item: &mut Item, raw: &Path, opts: &ImportOptions) {
706 let ext = item.ext.clone();
707 if item.url.is_empty() && weblink::is_link_file(&ext) {
708 item.url = weblink::read(raw).unwrap_or_default();
709 }
710 if (exif::is_raw(&ext) || (opts.camera_metadata_all_photos && exif::EXIF_EXTENSIONS.contains(&ext.as_str())))
711 && let Some(meta) = exif::read(raw)
712 {
713 item.extra.insert("rawMetas".into(), Value::Object(meta.to_raw_metas()));
714 }
715 if media::is_video(&ext) {
716 item.extra.insert("resolutionWidth".into(), json!(item.width));
717 item.extra.insert("resolutionHeight".into(), json!(item.height));
718 }
719 if (media::is_video(&ext) || media::is_audio(&ext))
720 && let Some(d) = media::duration(raw, &ext).or_else(|| thumbnail::spotlight(raw).duration)
721 {
722 item.extra.insert("duration".into(), json!(d));
723 }
724 if media::is_audio(&ext)
725 && let Some(bpm) = tempo::bpm(raw, &ext)
726 {
727 item.extra.insert("bpm".into(), tempo::json_value(bpm));
728 }
729 if FONT_EXTENSIONS.contains(&ext.as_str())
730 && let Some(metas) = fs::read(raw).ok().and_then(|data| fontmeta::font_metas(&data, &ids::new_id()))
731 {
732 if opts.font_weight_tag
733 && let Some(w) = metas.get("weight").and_then(|w| w.as_str())
734 {
735 item.tags.push(w.to_string());
736 }
737 item.extra.insert("fontMetas".into(), Value::Object(metas));
738 }
739}
740
741fn add_keyword_tags(tags: &mut Vec<String>, keywords: Vec<String>) {
744 if keywords.is_empty() {
745 return;
746 }
747 let mut seen = std::collections::HashSet::new();
748 let all: Vec<String> = tags.drain(..).chain(keywords).filter(|t| seen.insert(t.clone())).collect();
749 *tags = all;
750}
751
752pub const FONT_EXTENSIONS: [&str; 5] = ["ttf", "otf", "ttc", "woff", "woff2"];
754
755pub(crate) fn sanitize_name(stem: &str, ext: &str) -> String {
758 let cleaned: String = stem
759 .chars()
760 .filter(|c| !c.is_control() && !"/?<>\\:*|\"%".contains(*c))
761 .collect();
762 let cleaned: String = cleaned.nfc().collect();
763 let cleaned = cleaned.trim().trim_end_matches('.').trim();
764 let room = MAX_FILE_NAME.saturating_sub(THUMB_SUFFIX.len().max(ext.len() + 1));
765 let mut out = String::new();
766 for c in cleaned.chars() {
767 if out.len() + c.len_utf8() > room {
768 break;
769 }
770 out.push(c);
771 }
772 let out = out.trim().to_string();
773 #[cfg(windows)]
774 if crate::disk_name::is_device(out.split('.').next().unwrap_or(&out)) {
775 return format!("{out}_");
776 }
777 if out.is_empty() || out == "." || out == ".." { "_".into() } else { out }
778}
779
780pub(crate) fn restat(item: &mut Item, path: &Path) -> Result<()> {
783 let meta = fs::metadata(path).map_err(|e| Error::io(path, e))?;
784 item.size = meta.len();
785 item.mtime = to_ms(meta.modified().ok());
786 item.btime = meta.created().ok().map_or(item.mtime, |t| to_ms(Some(t)));
787 Ok(())
788}
789
790fn to_ms(t: Option<SystemTime>) -> u64 {
791 t.and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map_or(0, |d| d.as_millis() as u64)
792}
793
794fn now_ms() -> u64 {
795 to_ms(Some(SystemTime::now()))
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801
802 fn import(file: &str, bytes: &[u8], opts: &ImportOptions) -> Item {
803 import_with_thumb(file, bytes, opts).0
804 }
805
806 #[test]
807 fn a_given_name_replaces_the_file_name_and_keeps_the_extension() {
808 let named = ImportOptions { name: Some(" My: note ".into()), ..Default::default() };
809 let item = import("raw.txt", b"hi", &named);
810 assert_eq!((item.name.as_str(), item.ext.as_str()), ("My note", "txt"));
811 assert_eq!(import("raw.txt", b"hi", &ImportOptions::default()).name, "raw");
812 }
813
814 #[test]
815 fn items_from_another_app_keep_their_trash_state_and_date() {
816 let opts = ImportOptions { trashed: true, added_at: Some(1_520_139_967_000), ..Default::default() };
817 let item = import("old.txt", b"hi", &opts);
818 assert!(item.is_deleted);
819 assert_eq!(item.modification_time, 1_520_139_967_000);
820 assert!(!import("new.txt", b"hi", &ImportOptions::default()).is_deleted);
821 }
822
823 fn import_with_thumb(file: &str, bytes: &[u8], opts: &ImportOptions) -> (Item, Option<(u32, u32)>) {
825 let tmp = tempfile::tempdir().unwrap();
826 let mut lib = Library::create(tmp.path().join("T.library")).unwrap();
827 let src = tmp.path().join(file);
828 fs::write(&src, bytes).unwrap();
829 let id = lib.import_file(&src, opts).unwrap();
830 let item = lib.item(&id).unwrap().clone();
831 let thumb = item.thumbnail_path(lib.root()).and_then(|p| thumbnail::decode(&p).ok()).map(|i| (i.width(), i.height()));
832 (item, thumb)
833 }
834
835 fn keys(item: &Item) -> Vec<&str> {
836 item.extra.keys().map(|k| k.as_str()).collect()
837 }
838
839 fn metadata_only(file: &str, bytes: &[u8], opts: &ImportOptions) -> Item {
842 let tmp = tempfile::tempdir().unwrap();
843 let src = tmp.path().join(file);
844 fs::write(&src, bytes).unwrap();
845 let mut item: Item = serde_json::from_value(json!({
846 "id": "", "name": "shot", "size": bytes.len(), "btime": 0, "mtime": 0, "ext": src.extension().unwrap().to_str(),
847 "tags": [], "folders": [], "isDeleted": false, "url": "", "annotation": "", "modificationTime": 0,
848 "height": 0, "width": 0, "lastModified": 0, "palettes": []
849 }))
850 .unwrap();
851 file_metadata(&mut item, &src, opts);
852 item
853 }
854
855 #[test]
856 fn link_files_set_the_item_url() {
857 let none = ImportOptions::default();
858 let url = metadata_only("go.url", b"[InternetShortcut]\r\nURL=https://example.com/a\r\n", &none);
859 assert_eq!(url.url, "https://example.com/a");
860 let xml = b"<plist><dict><key>URL</key><string>https://example.com/w</string></dict></plist>";
861 assert_eq!(metadata_only("go.webloc", xml, &none).url, "", "a .webloc is a plain item, as in Eagle");
862 assert_eq!(metadata_only("empty.url", b"[InternetShortcut]\n", &none).url, "");
863 assert_eq!(metadata_only("note.txt", b"[InternetShortcut]\nURL=https://example.com/\n", &none).url, "");
864
865 let tmp = tempfile::tempdir().unwrap();
867 let src = tmp.path().join("go.url");
868 fs::write(&src, "[InternetShortcut]\nURL=https://example.com/a\n").unwrap();
869 let mut item = metadata_only("x.txt", b"hi", &none);
870 item.ext = "url".into();
871 item.url = "https://given.example/".into();
872 let given = ImportOptions { url: item.url.clone(), ..Default::default() };
873 file_metadata(&mut item, &src, &given);
874 assert_eq!(item.url, "https://given.example/");
875 }
876
877 #[test]
878 fn failures_are_classified() {
879 let tmp = tempfile::tempdir().unwrap();
880 let opts = ImportOptions { palette: false, ..Default::default() };
881 let fail = |p: &Path| Failure::new(p, &stage(p, &opts).unwrap_err());
882
883 let missing = tmp.path().join("gone.png");
884 assert_eq!(fail(&missing).kind, FailKind::Missing);
885 assert_eq!(fail(tmp.path()).kind, FailKind::NotAFile);
886 let empty = tmp.path().join("empty.png");
887 fs::write(&empty, b"").unwrap();
888 let f = fail(&empty);
889 assert_eq!((f.kind, f.path.as_path()), (FailKind::Empty, empty.as_path()));
890 assert!(f.detail.contains("empty"), "{}", f.detail);
891
892 for name in ["README", "movie.mp4.crdownload", "song.part", "page.download"] {
893 let p = tmp.path().join(name);
894 fs::write(&p, b"data").unwrap();
895 assert_eq!(fail(&p).kind, FailKind::Unrecognized, "{name}");
896 }
897 let odd = tmp.path().join("notes.xyz123");
899 fs::write(&odd, b"data").unwrap();
900 assert!(stage(&odd, &opts).is_ok());
901
902 let mut lib = Library::create(tmp.path().join("T.library")).unwrap();
903 let ok = tmp.path().join("a.txt");
904 fs::write(&ok, b"hi").unwrap();
905 let bad = ImportOptions { folders: vec!["NOPE".into()], ..opts.clone() };
906 let err = lib.import_file(&ok, &bad).unwrap_err();
907 assert_eq!(Failure::new(&ok, &err).kind, FailKind::UnknownFolder);
908
909 let full = Error::io(tmp.path().join("falcon-importXYZ/a.png"), std::io::Error::from(std::io::ErrorKind::StorageFull));
910 assert_eq!(Failure::new(&ok, &full).kind, FailKind::NoSpace);
911 let copy = Error::io(tmp.path().join("falcon-importXYZ/a.png"), std::io::Error::other("boom"));
912 assert_eq!(Failure::new(&ok, ©).kind, FailKind::CopyFailed);
913 let write = Error::io(tmp.path().join("T.library/images/X.info"), std::io::Error::other("boom"));
914 assert_eq!(Failure::new(&ok, &write).kind, FailKind::LibraryWrite);
915 #[cfg(unix)]
916 {
917 use std::os::unix::fs::PermissionsExt;
918 let locked = tmp.path().join("locked.png");
919 fs::write(&locked, b"x").unwrap();
920 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap();
921 if fs::read(&locked).is_err() {
923 assert_eq!(fail(&locked).kind, FailKind::Unreadable);
924 }
925 }
926 #[cfg(windows)]
929 {
930 use std::os::windows::fs::OpenOptionsExt;
931 let locked = tmp.path().join("locked.png");
932 fs::write(&locked, b"x").unwrap();
933 let held = fs::OpenOptions::new().read(true).share_mode(0).open(&locked).unwrap();
934 assert_eq!(fail(&locked).kind, FailKind::Unreadable);
935 drop(held);
936 }
937 }
938
939 #[test]
940 fn raw_photo_gets_raw_metas() {
941 let opts = ImportOptions { palette: false, ..Default::default() };
942 let item = metadata_only("shot.nef", &crate::exif::tests::tiff_with_exif(), &opts);
943 let m = &item.extra["rawMetas"];
944 assert_eq!(m["camera"], "Nikon D850");
945 assert_eq!(m["isoSpeed"], 400);
946 assert_eq!(m["shutter"], "1/250.0 sec");
947 assert_eq!(m["aperture"], "f/2.8");
948 assert_eq!(m["focalLength"], "50.0 mm");
949 let item = metadata_only("shot.tif", &crate::exif::tests::tiff_with_exif(), &opts);
951 assert!(!item.extra.contains_key("rawMetas"));
952 let all = ImportOptions { camera_metadata_all_photos: true, ..opts };
953 let item = metadata_only("shot.tif", &crate::exif::tests::tiff_with_exif(), &all);
954 assert!(item.extra.contains_key("rawMetas"));
955 }
956
957 #[test]
958 fn keywords_become_tags_when_asked() {
959 let png = crate::keywords::tests::png_with_xmp(true);
960 let opts = ImportOptions { palette: false, tags: vec!["beach".into(), "mine".into()], ..Default::default() };
961 assert_eq!(import("k.png", &png, &opts).tags, ["beach", "mine"]);
962 let on = ImportOptions { keywords_as_tags: true, ..opts };
963 assert_eq!(import("k.png", &png, &on).tags, ["beach", "mine", "sunset", "sea & sky"]);
964 }
965
966 #[test]
967 fn audio_gets_duration() {
968 let opts = ImportOptions { palette: false, ..Default::default() };
969 let (item, thumb) = import_with_thumb("tone.wav", &crate::media::tests::wav(8000, 1.25, |t| (t * 300.0).sin()), &opts);
970 assert_eq!(item.extra["duration"], 1.25);
971 assert_eq!((item.width, item.height), (280, 140));
972 assert_eq!(thumb, Some((280, 140)), "the waveform is the thumbnail");
973 assert!(!item.extra.contains_key("bpm"), "a steady tone has no beat");
974 }
975
976 #[test]
977 fn video_macos_cannot_read_is_thumbnailed_through_ffmpeg() {
978 if !falcon_ffmpeg::available() {
979 eprintln!("skipped: FFmpeg is not installed (scripts/build-ffmpeg.sh)");
980 return;
981 }
982 let clip = falcon_ffmpeg::testing::Clip { width: 64, height: 48, frames: 50, fps: 25, audio_rate: Some(8000) };
985 let avi = clip.avi(|i, _| if i < 25 { [0, 0, 0] } else { [200, 40, 40] });
986 let (item, thumb) = import_with_thumb("clip.avi", &avi, &ImportOptions::default());
987 assert_eq!((item.width, item.height), (64, 48));
988 assert_eq!(thumb, Some((480, 360)), "a 480-wide thumbnail as Eagle draws");
989 assert!((item.extra["duration"].as_f64().unwrap() - 2.0).abs() < 0.05, "{}", item.extra["duration"]);
990 assert_eq!((item.extra["resolutionWidth"].clone(), item.extra["resolutionHeight"].clone()), (json!(64), json!(48)));
991 let reddish = item.palettes.first().is_some_and(|p| p.color[0] > 150 && p.color[1] < 90);
992 assert!(reddish, "the thumbnail is the first non-black frame: {:?}", item.palettes.first().map(|p| p.color));
993 }
994
995 #[test]
996 fn av1_video_is_thumbnailed_through_dav1d() {
997 if !falcon_ffmpeg::ffi::api().is_ok_and(|api| falcon_ffmpeg::ffi::has_decoder(api, "libdav1d")) {
998 eprintln!("skipped: FFmpeg with dav1d is not installed (scripts/build-ffmpeg.sh)");
999 return;
1000 }
1001 let ivf = include_bytes!("../../falcon-ffmpeg/testdata/av1.ivf");
1006 let clip = falcon_ffmpeg::testing::av1::Av1Clip::from_ivf(ivf).unwrap();
1007 for (name, bytes) in [("clip.webm", clip.matroska(true)), ("clip.mkv", clip.matroska(false)), ("clip.mp4", clip.mp4())] {
1008 let (item, thumb) = import_with_thumb(name, &bytes, &ImportOptions::default());
1009 assert_eq!((item.width, item.height), (64, 48), "{name}");
1010 assert!((item.extra["duration"].as_f64().unwrap() - 1.0).abs() < 0.05, "{name}: {}", item.extra["duration"]);
1011 let c = item.palettes.first().map(|p| p.color).unwrap();
1012 assert!(c[1].abs_diff(100) < 16 && c[2].abs_diff(200) < 16, "{name}: a frame of the clip, not black or an icon: {c:?}");
1013 if name.ends_with(".mp4") && thumb != Some((480, 360)) {
1014 assert!(thumb.is_some(), "{name}");
1017 continue;
1018 }
1019 assert_eq!(thumb, Some((480, 360)), "{name}: a 480-wide thumbnail as Eagle draws");
1020 assert!(c[0].abs_diff(66) < 16, "{name}: {c:?}");
1022 }
1023 }
1024
1025 #[test]
1026 fn audio_with_a_beat_gets_bpm_after_duration() {
1027 let opts = ImportOptions { palette: false, ..Default::default() };
1028 let click = |t: f64| { let s = t % 0.5; if s < 0.004 { 0.8 * (s * 9000.0).sin() } else { 0.0 } };
1029 let item = metadata_only("beat.wav", &crate::media::tests::wav(22050, 12.0, click), &opts);
1030 let bpm = item.extra["bpm"].as_f64().unwrap();
1031 assert!((bpm - 120.0).abs() <= 1.0, "{bpm}");
1032 let keys: Vec<&str> = item.extra.keys().map(String::as_str).collect();
1033 assert_eq!(keys, ["duration", "bpm"]);
1034 }
1035
1036 #[test]
1037 fn text_files_keep_their_text_in_eagles_record_layout() {
1038 let tmp = tempfile::tempdir().unwrap();
1039 let root = tmp.path().join("T.library");
1040 let mut lib = Library::create(&root).unwrap();
1041 let src = tmp.path().join("Notes.txt");
1042 fs::write(&src, "Line one\nLine two\n").unwrap();
1043 let id = lib.import_file(&src, &ImportOptions::default()).unwrap();
1044 let item = lib.item(&id).unwrap().clone();
1045 assert_eq!(item.extra["text"], "Line one\nLine two\n");
1046 assert!(item.thumbnail_path(lib.root()).is_none_or(|p| !p.exists()), "no thumbnail file");
1047 let json: Value = serde_json::from_slice(&fs::read(item.info_dir(lib.root()).join("metadata.json")).unwrap()).unwrap();
1048 let written: Vec<&str> = json.as_object().unwrap().keys().map(|k| k.as_str()).collect();
1049 assert_eq!(written, crate::text::RECORD_KEYS);
1050
1051 let empty = tmp.path().join("Untitled.txt");
1053 fs::write(&empty, "").unwrap();
1054 let id2 = lib.import_file(&empty, &ImportOptions::default()).unwrap();
1055 assert_eq!(lib.item(&id2).unwrap().extra["text"], "");
1056
1057 let raw = item.original_path(lib.root());
1060 fs::write(&raw, "Rewritten").unwrap();
1061 let mut stale = lib.item(&id).unwrap().clone();
1062 stale.extra.shift_remove("text");
1063 lib.update_item(stale).unwrap();
1064 assert!(lib.refresh_thumbnail(&id).unwrap());
1065 assert_eq!(Library::open(&root).unwrap().item(&id).unwrap().extra["text"], "Rewritten");
1066 }
1067
1068 #[test]
1069 fn refresh_thumbnail_takes_the_tempo_again() {
1070 let tmp = tempfile::tempdir().unwrap();
1071 let root = tmp.path().join("T.library");
1072 let mut lib = Library::create(&root).unwrap();
1073 let src = tmp.path().join("beat.wav");
1074 let click = |t: f64| { let s = t % 0.5; if s < 0.004 { 0.8 * (s * 9000.0).sin() } else { 0.0 } };
1075 fs::write(&src, crate::media::tests::wav(22050, 12.0, click)).unwrap();
1076 let opts = ImportOptions { palette: false, ..Default::default() };
1077 let id = lib.import_file(&src, &opts).unwrap();
1078 let mut item = lib.item(&id).unwrap().clone();
1080 item.extra.insert("bpm".into(), json!(90.5));
1081 item.extra.insert("star".into(), json!(3));
1082 lib.update_item(item).unwrap();
1083 let mut lib = Library::open(&root).unwrap();
1084 assert!(lib.refresh_thumbnail(&id).unwrap());
1085 let lib = Library::open(&root).unwrap();
1086 let item = lib.item(&id).unwrap();
1087 let bpm = item.extra["bpm"].as_f64().unwrap();
1088 assert!((bpm - 120.0).abs() <= 1.0, "{bpm}");
1089 assert_eq!(keys(item), ["duration", "bpm", "star"]);
1090
1091 let raw = item.original_path(lib.root());
1093 fs::write(&raw, crate::media::tests::wav(8000, 1.25, |t| (t * 300.0).sin())).unwrap();
1094 let mut lib = Library::open(&root).unwrap();
1095 assert!(lib.refresh_thumbnail(&id).unwrap());
1096 assert_eq!(keys(Library::open(&root).unwrap().item(&id).unwrap()), ["duration", "star"]);
1097 }
1098
1099 #[test]
1100 fn font_gets_font_metas_and_weight_tag() {
1101 let Ok(data) = fs::read("/System/Library/Fonts/Supplemental/Arial.ttf") else { return };
1102 let opts = ImportOptions { palette: false, tags: vec!["mine".into()], ..Default::default() };
1103 let (item, thumb) = import_with_thumb("Arial.ttf", &data, &opts);
1104 assert_eq!((item.width, item.height), (600, 600));
1105 assert_eq!(thumb, Some((320, 320)), "the specimen is scaled to the thumbnail limit");
1106 assert_eq!(item.extra["fontMetas"]["postScriptName"]["en"], "ArialMT");
1107 assert_eq!(item.tags, ["mine", "Regular"]);
1108 assert!(keys(&item).contains(&"fontMetas"));
1109 let item = import("Arial.ttf", &data, &ImportOptions { font_weight_tag: false, ..opts });
1110 assert_eq!(item.tags, ["mine"]);
1111 }
1112
1113 fn jpeg_bytes(w: u32, h: u32) -> Vec<u8> {
1114 let mut out = Vec::new();
1115 DynamicImage::ImageRgb8(image::RgbImage::from_pixel(w, h, image::Rgb([180, 40, 20])))
1116 .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Jpeg)
1117 .unwrap();
1118 out
1119 }
1120
1121 fn tiff_raw(size: (u32, u32), orientation: u16, preview: &[u8]) -> Vec<u8> {
1124 let entry = |tag: u16, v: u32| [&tag.to_le_bytes()[..], &4u16.to_le_bytes(), &1u32.to_le_bytes(), &v.to_le_bytes()].concat();
1125 let mut out = b"II*\0\x08\0\0\0".to_vec();
1126 out.extend(3u16.to_le_bytes());
1127 out.extend(entry(0x100, size.0));
1128 out.extend(entry(0x101, size.1));
1129 out.extend(entry(0x112, orientation.into()));
1130 out.extend(0u32.to_le_bytes());
1131 out.extend(preview);
1132 out
1133 }
1134
1135 fn embedded_only(file: &str, bytes: &[u8]) -> Option<((u64, u64), (u32, u32))> {
1138 let tmp = tempfile::tempdir().unwrap();
1139 let src = tmp.path().join(file);
1140 fs::write(&src, bytes).unwrap();
1141 let mut item: Item = serde_json::from_value(json!({
1142 "id": "", "name": "x", "size": bytes.len(), "btime": 0, "mtime": 0, "ext": src.extension().unwrap().to_str(),
1143 "tags": [], "folders": [], "isDeleted": false, "url": "", "annotation": "", "modificationTime": 0,
1144 "height": 0, "width": 0, "lastModified": 0, "palettes": []
1145 }))
1146 .unwrap();
1147 let thumb = tmp.path().join("t.png");
1148 embedded_thumbnail(&mut item, &src, &thumb, 320)?;
1149 let img = thumbnail::decode(&thumb).unwrap();
1150 Some(((item.width, item.height), (img.width(), img.height())))
1151 }
1152
1153 fn pdf_bytes(w: u32, h: u32) -> Vec<u8> {
1155 let content = "1 0 0 rg 0 0 50 50 re f";
1156 let objs = [
1157 "<< /Type /Catalog /Pages 2 0 R >>".to_string(),
1158 "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
1159 format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents 4 0 R >>"),
1160 format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
1161 ];
1162 let mut out = b"%PDF-1.4\n".to_vec();
1163 let mut offsets = Vec::new();
1164 for (k, body) in objs.iter().enumerate() {
1165 offsets.push(out.len());
1166 out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", k + 1).as_bytes());
1167 }
1168 let xref = out.len();
1169 out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
1170 for o in offsets {
1171 out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
1172 }
1173 out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
1174 out
1175 }
1176
1177 #[cfg(target_os = "macos")]
1178 #[test]
1179 fn pdf_compatible_illustrator_files_draw_page_one() {
1180 for name in ["a.ai", "a.ait"] {
1181 let (dims, thumb) = embedded_only(name, &pdf_bytes(200, 100)).expect(name);
1182 assert_eq!(dims, (200, 100), "{name}: the page's size");
1183 assert!(thumb.0 > thumb.1, "{name}: landscape, {thumb:?}");
1184 }
1185 }
1186
1187 #[test]
1188 fn illustrator_files_with_no_page_fall_back_to_their_postscript_preview() {
1189 let ps = b"%!PS-Adobe-3.0\n%%BeginPreview: 2 2 8 2\n%00ff\n%8040\n%%EndPreview\n";
1190 assert_eq!(embedded_only("old.ai", ps).unwrap().0, (2, 2));
1191 assert!(embedded_only("stub.ai", b"%!PS-Adobe-3.0\n%%Creator: x\n").is_none());
1193 assert!(embedded_only("bad.ai", b"%PDF-1.4\nnot really").is_none());
1194 assert!(embedded_only("empty.ait", b"").is_none());
1195 }
1196
1197 #[test]
1198 fn binary_and_zip_previews_reach_the_thumbnail() {
1199 let mut blend = b"BLENDER-v305".to_vec();
1201 let mut body = Vec::new();
1202 body.extend(3u32.to_le_bytes());
1203 body.extend(2u32.to_le_bytes());
1204 body.extend([9u8; 3 * 2 * 4]);
1205 blend.extend(b"TEST");
1206 blend.extend((body.len() as u32).to_le_bytes());
1207 blend.extend([0u8; 16]);
1208 blend.extend(&body);
1209 assert_eq!(embedded_only("a.blend", &blend).unwrap().0, (3, 2));
1210 for name in ["a.eps", "a.indd", "a.indt", "a.skp", "a.dwg", "a.idml", "a.key"] {
1212 assert!(embedded_only(name, b"nothing here").is_none(), "{name}");
1213 }
1214 }
1215
1216 #[test]
1217 fn raw_without_a_quicklook_render_uses_its_embedded_preview() {
1218 let preview = jpeg_bytes(96, 64);
1219 let raw = tiff_raw((6000, 4000), 1, &preview);
1221 let (dims, thumb) = embedded_only("a.nef", &raw).unwrap();
1222 assert_eq!(dims, (6000, 4000));
1223 assert_eq!(thumb, (96, 64));
1224 let (dims, thumb) = embedded_only("a.orf", &tiff_raw((6000, 4000), 6, &preview)).unwrap();
1225 assert_eq!(dims, (4000, 6000));
1226 assert_eq!(thumb, (64, 96), "the thumbnail is upright too");
1227 let (dims, _) = embedded_only("a.nef", &tiff_raw((32, 21), 1, &preview)).unwrap();
1229 assert_eq!(dims, (96, 64));
1230 let mut raf = b"FUJIFILMCCD-RAW 0201FF383501".to_vec();
1232 raf.resize(100, 0);
1233 raf[84..88].copy_from_slice(&100u32.to_be_bytes());
1234 raf[88..92].copy_from_slice(&(preview.len() as u32).to_be_bytes());
1235 raf.extend_from_slice(&preview);
1236 assert_eq!(embedded_only("a.raf", &raf).unwrap().0, (96, 64));
1237 }
1238
1239 #[test]
1240 fn raw_without_a_preview_or_malformed_gets_no_thumbnail() {
1241 for ext in ["crw", "mrw", "orf", "raf", "raw", "srw", "x3f"] {
1242 let file = format!("bad.{ext}");
1243 assert!(embedded_only(&file, b"").is_none(), "{ext} empty");
1244 assert!(embedded_only(&file, &[0xFF; 300]).is_none(), "{ext} noise");
1245 assert!(embedded_only(&file, &tiff_raw((100, 100), 1, b"no picture here")).is_none(), "{ext} no jpeg");
1246 let jpeg = jpeg_bytes(64, 64);
1247 assert!(embedded_only(&file, &tiff_raw((100, 100), 1, &jpeg[..jpeg.len() / 3])).is_none(), "{ext} cut");
1249 }
1250 let mut raf = b"FUJIFILMCCD-RAW 0201FF383501".to_vec();
1251 raf.resize(100, 0);
1252 raf[84..88].copy_from_slice(&u32::MAX.to_be_bytes());
1253 raf[88..92].copy_from_slice(&u32::MAX.to_be_bytes());
1254 assert!(embedded_only("a.raf", &raf).is_none());
1255 assert!(embedded_only("a.x3f", b"FOVb\0\0\0\0\xFF\xFF\xFF\xFF").is_none());
1256 assert!(embedded_only("a.mrw", b"\0MRM\xFF\xFF\xFF\xFF\0TTW\xFF\xFF\xFF\xFF").is_none());
1257 }
1258
1259 #[test]
1260 fn document_previews_become_thumbnails() {
1261 use std::io::Write;
1262 let zipped = |entries: &[(&str, &[u8])]| {
1263 let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
1264 for (name, data) in entries {
1265 z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
1266 z.write_all(data).unwrap();
1267 }
1268 z.finish().unwrap().into_inner()
1269 };
1270 let mut png = Vec::new();
1271 DynamicImage::ImageRgb8(image::RgbImage::new(40, 30)).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
1272 let sketch = zipped(&[("previews/preview.png", &png)]);
1273 assert_eq!(embedded_only("a.sketch", &sketch), Some(((40, 30), (40, 30))));
1274 assert_eq!(embedded_only("a.xmind", &zipped(&[("Thumbnails/thumbnail.png", &png)])), Some(((40, 30), (40, 30))));
1275 assert_eq!(embedded_only("a.key", &zipped(&[("preview.jpg", &jpeg_bytes(20, 10))])), Some(((20, 10), (20, 10))));
1276 assert_eq!(embedded_only("a.key", &zipped(&[("QuickLook/Thumbnail.jpg", &jpeg_bytes(10, 20))])), Some(((10, 20), (10, 20))));
1277 assert!(embedded_only("a.sketch", &zipped(&[("document.json", b"{}")])).is_none());
1279 assert!(embedded_only("a.sketch", &zipped(&[("previews/preview.png", b"junk")])).is_none());
1280 assert!(embedded_only("a.xmind", b"not a zip").is_none());
1281 assert!(embedded_only("a.key", &sketch[..sketch.len() - 5]).is_none());
1282 assert!(embedded_only("a.pages", &sketch).is_none());
1284 }
1285}