fm/modes/display/
image.rs

1use anyhow::{anyhow, bail, Context, Result};
2
3use std::ffi::OsStr;
4use std::fs::metadata;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::{Duration, Instant, SystemTime};
8
9use crate::common::{
10    filename_from_path, hash_path, path_to_string, FFMPEG, FONTIMAGE, LIBREOFFICE, PDFINFO,
11    PDFTOPPM, RSVG_CONVERT, THUMBNAIL_PATH_NO_EXT, THUMBNAIL_PATH_PNG, TMP_THUMBNAILS_DIR,
12};
13use crate::io::{execute_and_capture_output, execute_and_output_no_log};
14use crate::log_info;
15use crate::modes::ExtensionKind;
16
17/// Different kind of ueberzug previews.
18/// it's used to know which program should be run to build the images.
19/// pdfs, or office documents can't be displayed directly in the terminal and require
20/// to be converted first.
21#[derive(Default)]
22pub enum Kind {
23    Font,
24    Image,
25    Office,
26    Pdf,
27    Svg,
28    Video,
29    #[default]
30    Unknown,
31}
32
33impl Kind {
34    fn allow_multiples(&self) -> bool {
35        matches!(self, Self::Pdf)
36    }
37
38    pub fn for_first_line(&self) -> &str {
39        match self {
40            Self::Font => "a font",
41            Self::Image => "an image",
42            Self::Office => "an office document",
43            Self::Pdf => "a pdf",
44            Self::Svg => "an svg image",
45            Self::Video => "a video",
46            Self::Unknown => "Unknown",
47        }
48    }
49}
50
51impl From<ExtensionKind> for Kind {
52    fn from(kind: ExtensionKind) -> Self {
53        match &kind {
54            ExtensionKind::Font => Self::Font,
55            ExtensionKind::Image => Self::Image,
56            ExtensionKind::Office => Self::Office,
57            ExtensionKind::Pdf => Self::Pdf,
58            ExtensionKind::Svg => Self::Svg,
59            ExtensionKind::Video => Self::Video,
60            _ => Self::Unknown,
61        }
62    }
63}
64impl std::fmt::Display for Kind {
65    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
66        match self {
67            Self::Font => write!(f, "font"),
68            Self::Image => write!(f, "image"),
69            Self::Office => write!(f, "office"),
70            Self::Pdf => write!(f, "pdf"),
71            Self::Svg => write!(f, "svg"),
72            Self::Unknown => write!(f, "unknown"),
73            Self::Video => write!(f, "video"),
74        }
75    }
76}
77
78/// True iff the path points to a video file. Recognized from its extenion.
79pub fn path_is_video<P: AsRef<Path>>(path: P) -> bool {
80    let Some(ext) = path.as_ref().extension() else {
81        return false;
82    };
83    matches!(
84        ext.to_string_lossy().as_ref(),
85        "mkv" | "webm" | "mpeg" | "mp4" | "avi" | "flv" | "mpg" | "wmv" | "m4v" | "mov"
86    )
87}
88
89/// Holds the informations about the displayed image.
90/// it's used to display the image itself, calling `draw` with parameters for its position and dimension.
91pub struct DisplayedImage {
92    since: Instant,
93    pub kind: Kind,
94    pub identifier: String,
95    pub images: Vec<PathBuf>,
96    length: usize,
97    pub index: usize,
98}
99
100impl DisplayedImage {
101    fn new(kind: Kind, identifier: String, images: Vec<PathBuf>) -> Self {
102        let index = 0;
103        let length = images.len();
104        let since = Instant::now();
105        Self {
106            since,
107            kind,
108            identifier,
109            images,
110            length,
111            index,
112        }
113    }
114
115    pub fn filepath(&self) -> Arc<Path> {
116        Arc::from(self.images[self.index].as_path())
117    }
118
119    /// Only affect pdf thumbnail. Will decrease the index if possible.
120    pub fn up_one_row(&mut self) {
121        if self.kind.allow_multiples() && self.index > 0 {
122            self.index -= 1;
123        }
124    }
125
126    /// Only affect pdf thumbnail. Will increase the index if possible.
127    pub fn down_one_row(&mut self) {
128        if self.kind.allow_multiples() && self.index + 1 < self.len() {
129            self.index += 1;
130        }
131    }
132
133    /// 0 for every kind except pdf where it's the number of pages.
134    pub fn len(&self) -> usize {
135        self.length
136    }
137
138    pub fn is_empty(&self) -> bool {
139        self.len() == 0
140    }
141
142    fn video_index(&self) -> usize {
143        let elapsed = self.since.elapsed().as_secs() as usize;
144        elapsed % self.images.len()
145    }
146
147    fn image_index(&self) -> usize {
148        if matches!(self.kind, Kind::Video) {
149            self.video_index()
150        } else {
151            self.index
152        }
153    }
154
155    /// Path of the currently selected image as a COW &str.
156    pub fn selected_path(&self) -> std::borrow::Cow<'_, str> {
157        self.images[self.image_index()].to_string_lossy()
158    }
159}
160
161/// Build an [`DisplayedImage`] instance for a given source.
162/// All thumbnails are built here.
163pub struct DisplayedImageBuilder {
164    kind: Kind,
165    source: PathBuf,
166}
167
168impl DisplayedImageBuilder {
169    pub fn video_thumbnails(hashed_path: &str) -> [String; 4] {
170        [
171            format!("{TMP_THUMBNAILS_DIR}/{hashed_path}_1.jpg"),
172            format!("{TMP_THUMBNAILS_DIR}/{hashed_path}_2.jpg"),
173            format!("{TMP_THUMBNAILS_DIR}/{hashed_path}_3.jpg"),
174            format!("{TMP_THUMBNAILS_DIR}/{hashed_path}_4.jpg"),
175        ]
176    }
177
178    pub fn new(source: &Path, kind: Kind) -> Self {
179        let source = source.to_path_buf();
180        Self { source, kind }
181    }
182
183    pub fn build(self) -> Result<DisplayedImage> {
184        match &self.kind {
185            Kind::Font => self.build_font(),
186            Kind::Image => self.build_image(),
187            Kind::Office => self.build_office(),
188            Kind::Pdf => self.build_pdf(),
189            Kind::Svg => self.build_svg(),
190            Kind::Video => self.build_video(),
191            _ => Err(anyhow!("Unknown kind {kind}", kind = self.kind)),
192        }
193    }
194
195    fn build_office(self) -> Result<DisplayedImage> {
196        let calc_str = path_to_string(&self.source);
197        Self::convert_office_to_pdf(&calc_str)?;
198        let pdf = Self::office_to_pdf_filename(
199            self.source
200                .file_name()
201                .context("couldn't extract filename")?,
202        )?;
203        if !pdf.exists() {
204            bail!("couldn't convert {calc_str} to pdf");
205        }
206        let identifier = filename_from_path(&pdf)?.to_owned();
207        Thumbnail::create(&self.kind, pdf.to_string_lossy().as_ref());
208        let images = Self::make_pdf_images_paths(Self::get_pdf_length(&pdf)?)?;
209        std::fs::remove_file(&pdf)?;
210
211        Ok(DisplayedImage::new(Kind::Pdf, identifier, images))
212    }
213
214    fn convert_office_to_pdf(calc_str: &str) -> Result<std::process::Output> {
215        let args = ["--convert-to", "pdf", "--outdir", "/tmp", calc_str];
216        execute_and_output_no_log(LIBREOFFICE, args)
217    }
218
219    fn office_to_pdf_filename(filename: &OsStr) -> Result<PathBuf> {
220        let mut pdf_path = PathBuf::from("/tmp");
221        pdf_path.push(filename);
222        pdf_path.set_extension("pdf");
223        Ok(pdf_path)
224    }
225
226    fn make_pdf_images_paths(length: usize) -> Result<Vec<PathBuf>> {
227        let images = (1..length + 1)
228            .map(|index| PathBuf::from(format!("{THUMBNAIL_PATH_NO_EXT}-{index}.jpg")))
229            .filter(|p| p.exists())
230            .collect();
231        Ok(images)
232    }
233
234    fn get_pdf_length(path: &Path) -> Result<usize> {
235        let output =
236            execute_and_capture_output(PDFINFO, &[path.to_string_lossy().to_string().as_ref()])?;
237        let line = output.lines().find(|line| line.starts_with("Pages: "));
238
239        match line {
240            Some(line) => {
241                let page_count_str = line.split_whitespace().nth(1).unwrap();
242                let page_count = page_count_str.parse::<usize>()?;
243                log_info!(
244                    "pdf {path} has {page_count_str} pages",
245                    path = path.display()
246                );
247                Ok(page_count)
248            }
249            None => Err(anyhow!("Couldn't find the page number")),
250        }
251    }
252
253    fn build_pdf(self) -> Result<DisplayedImage> {
254        let length = Self::get_pdf_length(&self.source)?;
255        let identifier = filename_from_path(&self.source)?.to_owned();
256        Thumbnail::create(&self.kind, self.source.to_string_lossy().as_ref());
257        let images = Self::make_pdf_images_paths(length)?;
258        log_info!("build_pdf images: {images:?}");
259        Ok(DisplayedImage::new(self.kind, identifier, images))
260    }
261
262    fn build_video(self) -> Result<DisplayedImage> {
263        let path_str = self
264            .source
265            .to_str()
266            .context("make_thumbnail: couldn't parse the path into a string")?;
267        Thumbnail::create(&self.kind, path_str);
268        let hashed_path = hash_path(path_str);
269        let images: Vec<PathBuf> = Self::video_thumbnails(&hashed_path)
270            .map(PathBuf::from)
271            .into_iter()
272            .filter(|p| p.exists())
273            .collect();
274        let identifier = filename_from_path(&self.source)?.to_owned();
275        Ok(DisplayedImage::new(self.kind, identifier, images))
276    }
277
278    fn build_single_image(self, images: Vec<PathBuf>) -> Result<DisplayedImage> {
279        let identifier = filename_from_path(&self.source)?.to_owned();
280        Ok(DisplayedImage::new(self.kind, identifier, images))
281    }
282
283    fn build_font(self) -> Result<DisplayedImage> {
284        let path_str = self
285            .source
286            .to_str()
287            .context("make_thumbnail: couldn't parse the path into a string")?;
288        Thumbnail::create(&self.kind, path_str);
289        let p = PathBuf::from(THUMBNAIL_PATH_PNG);
290        let images = if p.exists() { vec![p] } else { vec![] };
291        self.build_single_image(images)
292    }
293
294    fn build_image(self) -> Result<DisplayedImage> {
295        let images = vec![self.source.clone()];
296        self.build_single_image(images)
297    }
298
299    fn build_svg(self) -> Result<DisplayedImage> {
300        let path_str = self
301            .source
302            .to_str()
303            .context("make_thumbnail: couldn't parse the path into a string")?;
304        Thumbnail::create(&self.kind, path_str);
305        let p = PathBuf::from(THUMBNAIL_PATH_PNG);
306        let images = if p.exists() { vec![p] } else { vec![] };
307        self.build_single_image(images)
308    }
309}
310
311/// Empty struct with methods relative to thubmnail creation.
312pub struct Thumbnail;
313
314impl Thumbnail {
315    fn create(kind: &Kind, path_str: &str) {
316        let _ = match kind {
317            Kind::Font => Self::create_font(path_str),
318            Kind::Office => Self::create_office(path_str),
319            Kind::Pdf => Self::create_pdf(path_str),
320            Kind::Svg => Self::create_svg(path_str),
321            Kind::Video => Self::create_video(path_str),
322
323            _ => Ok(()),
324        };
325    }
326
327    fn create_font(path_str: &str) -> Result<()> {
328        Self::execute(FONTIMAGE, &["-o", THUMBNAIL_PATH_PNG, path_str])
329    }
330
331    fn create_office(path_str: &str) -> Result<()> {
332        Self::create_pdf(path_str)
333    }
334
335    fn create_svg(path_str: &str) -> Result<()> {
336        Self::execute(
337            RSVG_CONVERT,
338            &["--keep-aspect-ratio", path_str, "-o", THUMBNAIL_PATH_PNG],
339        )
340    }
341
342    pub fn create_video(path_str: &str) -> Result<()> {
343        let rand = hash_path(path_str);
344        let images_paths = DisplayedImageBuilder::video_thumbnails(&rand);
345        if Path::new(&images_paths[0]).exists() && !is_older_than_a_week(&images_paths[0]) {
346            return Ok(());
347        }
348        for image in &images_paths {
349            let _ = std::fs::remove_file(image);
350        }
351        let ffmpeg_filename = format!("{TMP_THUMBNAILS_DIR}/{rand}_%d.jpg",);
352
353        let ffmpeg_args = [
354            "-i",
355            path_str,
356            "-an",
357            "-sn",
358            "-vf",
359            "fps=1/10,scale=320:-1",
360            "-threads",
361            "2",
362            "-frames:v",
363            "4",
364            &ffmpeg_filename,
365            // &format!("{THUMBNAIL_PATH_NO_EXT}_%d.jpg"),
366        ];
367        Self::execute(FFMPEG, &ffmpeg_args)
368    }
369
370    fn create_pdf(path_str: &str) -> Result<()> {
371        Self::execute(
372            PDFTOPPM,
373            &[
374                "-jpeg",
375                "-jpegopt",
376                "quality=75",
377                path_str,
378                THUMBNAIL_PATH_NO_EXT,
379            ],
380        )
381    }
382
383    fn execute(exe: &str, args: &[&str]) -> Result<()> {
384        let output = execute_and_output_no_log(exe, args.to_owned())?;
385        log_info!(
386            "make thumbnail error:  {}",
387            // String::from_utf8(output.stdout).unwrap_or_default(),
388            String::from_utf8(output.stderr).unwrap_or_default()
389        );
390        Ok(())
391    }
392}
393
394const ONE_WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60);
395
396fn is_older_than_a_week(path: &str) -> bool {
397    let Ok(metadata) = metadata(path) else {
398        return true;
399    };
400    let Ok(creation) = metadata.created() else {
401        return true;
402    };
403    let current_time = SystemTime::now();
404    let Ok(elapsed_since_creation) = current_time.duration_since(creation) else {
405        return true;
406    };
407    elapsed_since_creation > ONE_WEEK
408}