Repository: Canop/broot (language: rust)

Issue:
Use mutool draw for previewing PDFs
MuPDF's `mutool draw` command produces a decently high-resolution image form of a PDF. It can be specified to only produce an image of the first page, which would be nice for previewing PDFs.

Quick demo (after installing MuPDF):

```bash
$ mutool draw -o pdf-preview.png julia-1.5.0.pdf 1 # can add 2>/dev/null
page julia-1.5.0.pdf 1
$ kitty +kitten icat pdf-preview.png
```

Or directly through stdout:

```bash
mutool draw -o - julia-1.5.0.pdf 1 2>/dev/null | kitty +kitten icat
```


Discussion / hints:
PDF previews would allow me to use broot as my main file browser, I hope it can be added! In addition to mutool, the [Kitty icat](https://sw.kovidgoyal.net/kitty/kittens/icat/) method may be of interest. It uses ImageMagick to open both images and pdfs. (For a pdf, one generally needs a white background.)
`kitty +kitten icat --background white stuff.pdf`
> PDF previews would allow me to use broot as my main file browser, I hope it can be added! In addition to mutool, the [Kitty icat](https://sw.kovidgoyal.net/kitty/kittens/icat/) method may be of interest. It uses ImageMagick to open both images and pdfs. (For a pdf, one generally needs a white background.) `kitty +kitten icat --background white stuff.pdf`

Just discovered Broot. Amazing application. PDF previews are literately the only missing feature.



Fix patch:
diff --git a/resources/default-conf/conf.hjson b/resources/default-conf/conf.hjson
index 29f8909a..3742954a 100644
--- a/resources/default-conf/conf.hjson
+++ b/resources/default-conf/conf.hjson
@@ -216,6 +216,34 @@ enable_kitty_keyboard: false
 lines_before_match_in_preview: 1
 lines_after_match_in_preview: 1
 
+###############################################################
+# transformations before preview
+#
+# It's possible to define transformations to apply to some files
+# before calling one of the default preview renderers in broot.
+# Below are two examples that you may uncomment and adapt:
+#
+preview_transformers: [
+    // # Use mutool to render any PDF file as an image
+    // # In this example we use placeholders for the input and output files
+    // {
+    //     input_extensions: [ "pdf" ] // case doesn't matter
+    //     output_extension: png
+    //     mode: image
+    //     command: [ "mutool", "draw", "-w", "1000", "-o", "{output-path}", "{input-path}" ]
+    // }
+
+    // # Use jq to beautify JSON
+    // # In this example, the command refers to neither the input nor the output,
+    // # so broot pipes them to the stdin and stdout of the jq process
+    // {
+    //     input_extensions: [ "json" ] 
+    //     output_extension: json
+    //     mode: text
+    //     command: [ "jq" ]
+    // }
+]
+
 ###############################################################
 # Imports
 #
diff --git a/src/app/app_context.rs b/src/app/app_context.rs
index b772c02a..b9e717db 100644
--- a/src/app/app_context.rs
+++ b/src/app/app_context.rs
@@ -11,6 +11,7 @@ use {
         kitty::TransmissionMedium,
         pattern::SearchModeMap,
         path::SpecialPaths,
+        preview::PreviewTransformers,
         skin::ExtColorMap,
         syntactic::SyntaxTheme,
         tree::TreeOptions,
@@ -127,6 +128,9 @@ pub struct AppContext {
 
     /// Number of lines to display before a match in the preview
     pub lines_before_match_in_preview: usize,
+
+    /// The set of transformers called before previewing a file
+    pub preview_transformers: PreviewTransformers,
 }
 
 impl AppContext {
@@ -197,6 +201,8 @@ impl AppContext {
 
         let terminal_title_pattern = config.terminal_title.clone();
 
+        let preview_transformers = PreviewTransformers::new(&config.preview_transformers)?;
+
         Ok(Self {
             is_tty,
             initial_root,
@@ -229,6 +235,7 @@ impl AppContext {
                 .unwrap_or_default(),
             lines_after_match_in_preview: config.lines_after_match_in_preview.unwrap_or(0),
             lines_before_match_in_preview: config.lines_before_match_in_preview.unwrap_or(0),
+            preview_transformers,
         })
     }
     /// Return the --cmd argument, coming from the launch arguments (prefered)
diff --git a/src/conf/conf.rs b/src/conf/conf.rs
index 77a4d26f..a1d49383 100644
--- a/src/conf/conf.rs
+++ b/src/conf/conf.rs
@@ -12,6 +12,7 @@ use {
             path_from,
             PathAnchor,
         },
+        preview::PreviewTransformer,
         skin::SkinEntry,
         syntactic::SyntaxTheme,
         verb::ExecPattern,
@@ -39,6 +40,14 @@ macro_rules! overwrite_map {
     };
 }
 
+macro_rules! overwrite_vec {
+    ($dst: ident, $prop: ident, $src: ident) => {
+        for v in $src.$prop {
+            $dst.$prop.push(v);
+        }
+    };
+}
+
 /// The configuration read from conf.toml or conf.hjson file(s)
 #[derive(Default, Clone, Debug, Deserialize)]
 pub struct Conf {
@@ -86,6 +95,9 @@ pub struct Conf {
     #[serde(alias="kitty-graphics-transmission")]
     pub kitty_graphics_transmission: Option<TransmissionMedium>,
 
+    #[serde(default, alias="preview-transformers")]
+    pub preview_transformers: Vec<PreviewTransformer>,
+
     #[serde(alias="lines-after-match-in-preview")]
     pub lines_after_match_in_preview: Option<usize>,
 
@@ -221,10 +233,11 @@ impl Conf {
         overwrite!(self, lines_after_match_in_preview, conf);
         overwrite!(self, lines_before_match_in_preview, conf);
         self.verbs.append(&mut conf.verbs);
-        // the following maps are "additive": we can add entries from several
+        // the following prefs are "additive": we can add entries from several
         // config files and they still make sense
         overwrite_map!(self, special_paths, conf);
         overwrite_map!(self, ext_colors, conf);
+        overwrite_vec!(self, preview_transformers, conf);
         self.files.push(path);
         // read the imports
         for import in &conf.imports {
diff --git a/src/errors.rs b/src/errors.rs
index 789b091f..b66630a6 100644
--- a/src/errors.rs
+++ b/src/errors.rs
@@ -127,3 +127,9 @@ custom_error! {pub SvgError
     Internal { message: &'static str } = "Internal error : {message}",
     Svg {source: resvg::usvg::Error} = "SVG Error: {source}",
 }
+
+custom_error! {pub PreviewTransformerError
+    Io {source: io::Error} = "IO Error : {source}",
+    ProcessInterrupted = "Process interrupted",
+    ProcessFailed { code: i32 } = "Execution failed with code {code}",
+}
diff --git a/src/preview/mod.rs b/src/preview/mod.rs
index 6fcd8122..aabccd57 100644
--- a/src/preview/mod.rs
+++ b/src/preview/mod.rs
@@ -1,23 +1,26 @@
 mod dir_view;
 mod preview;
+mod preview_transformer;
 mod preview_state;
 mod zero_len_file_view;
 
 pub use {
     dir_view::DirView,
     preview::Preview,
+    preview_transformer::*,
     preview_state::PreviewState,
     zero_len_file_view::ZeroLenFileView,
 };
 
-#[derive(Debug, Clone, Copy, PartialEq)]
+#[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize)]
+#[serde(rename_all = "snake_case")]
 pub enum PreviewMode {
 
     /// image
     Image,
 
     /// show the content as text, with syntax coloring if
-    /// it makes sens. Fails if the file isn't in UTF8
+    /// it makes sense. Fails if the file isn't in UTF8
     Text,
 
     /// show the content of the file as hex
diff --git a/src/preview/preview_state.rs b/src/preview/preview_state.rs
index 470169af..eb180be7 100644
--- a/src/preview/preview_state.rs
+++ b/src/preview/preview_state.rs
@@ -26,7 +26,8 @@ use {
 pub struct PreviewState {
     pub preview_area: Area,
     dirty: bool,   // true when background must be cleared
-    path: PathBuf, // path to the previewed file
+    source_path: PathBuf, // path to the file whose preview is requested
+    transform: Option<PreviewTransform>,
     preview: Preview,
     pending_pattern: InputPattern, // a pattern (or not) which has not yet be applied
     filtered_preview: Option<Preview>,
@@ -38,18 +39,21 @@ pub struct PreviewState {
 
 impl PreviewState {
     pub fn new(
-        path: PathBuf,
+        source_path: PathBuf,
         pending_pattern: InputPattern,
         preferred_mode: Option<PreviewMode>,
         tree_options: TreeOptions,
         con: &AppContext,
     ) -> PreviewState {
         let preview_area = Area::uninitialized(); // will be fixed at drawing time
-        let preview = Preview::new(&path, preferred_mode, con);
+        let transform = con.preview_transformers.transform(&source_path, preferred_mode);
+        let preview_path = transform.as_ref().map(|c| &c.output_path).unwrap_or(&source_path);
+        let preview = Preview::new(preview_path, preferred_mode, con);
         PreviewState {
             preview_area,
             dirty: true,
-            path,
+            source_path,
+            transform,
             preview,
             pending_pattern,
             filtered_preview: None,
@@ -59,6 +63,9 @@ impl PreviewState {
             mode: con.initial_mode(),
         }
     }
+    pub fn preview_path(&self) -> &Path {
+        self.transform.as_ref().map(|c| &c.output_path).unwrap_or(&self.source_path)
+    }
     fn vis_preview(&self) -> &Preview {
         self.filtered_preview.as_ref().unwrap_or(&self.preview)
     }
@@ -73,7 +80,7 @@ impl PreviewState {
         if self.preview.get_mode() == Some(mode) {
             return Ok(CmdResult::Keep);
         }
-        Ok(match Preview::with_mode(&self.path, mode, con) {
+        Ok(match Preview::with_mode(self.preview_path(), mode, con) {
             Ok(preview) => {
                 self.preview = preview;
                 self.preferred_mode = Some(mode);
@@ -88,11 +95,20 @@ impl PreviewState {
     }
 
     fn no_opt_selection(&self) -> Selection<'_> {
-        Selection {
-            path: &self.path,
-            stype: SelectionType::File,
-            is_exe: false, // not always true. It means :open_leave won't execute it
-            line: self.vis_preview().get_selected_line_number().unwrap_or(0),
+        match self.transform.as_ref() {
+            // When there's a transform, we can't assume the line number makes sense
+            Some(transform) => Selection {
+                path: &transform.output_path,
+                stype: SelectionType::File,
+                is_exe: false,
+                line: 0,
+            },
+            None => Selection {
+                path: &self.source_path,
+                stype: SelectionType::File,
+                is_exe: false,
+                line: self.vis_preview().get_selected_line_number().unwrap_or(0),
+            },
         }
     }
 
@@ -159,7 +175,7 @@ impl PanelState for PreviewState {
             self.filtered_preview = time!(
                 Info,
                 "preview filtering",
-                self.preview.filtered(&self.path, pattern, dam, con),
+                self.preview.filtered(self.preview_path(), pattern, dam, con),
             ); // can be None if a cancellation was required
             if let Some(ref mut filtered_preview) = self.filtered_preview {
                 if let Some(number) = old_selection {
@@ -171,11 +187,11 @@ impl PanelState for PreviewState {
     }
 
     fn selected_path(&self) -> Option<&Path> {
-        Some(&self.path)
+        Some(&self.source_path)
     }
 
     fn set_selected_path(&mut self, path: PathBuf, con: &AppContext) {
-        let selected_line_number = if self.path == path {
+        let selected_line_number = if self.preview_path() == path {
             self.preview.get_selected_line_number()
         } else {
             None
@@ -183,11 +199,13 @@ impl PanelState for PreviewState {
         if let Some(fp) = &self.filtered_preview {
             self.pending_pattern = fp.pattern();
         };
-        self.preview = Preview::new(&path, self.preferred_mode, con);
+        self.transform = con.preview_transformers.transform(&path, self.preferred_mode);
+        let preview_path = self.transform.as_ref().map(|c| &c.output_path).unwrap_or(&path);
+        self.preview = Preview::new(preview_path, self.preferred_mode, con);
         if let Some(number) = selected_line_number {
             self.preview.try_select_line_number(number);
         }
-        self.path = path;
+        self.source_path = path;
     }
 
     fn selection(&self) -> Option<Selection<'_>> {
@@ -211,7 +229,7 @@ impl PanelState for PreviewState {
 
     fn refresh(&mut self, _screen: Screen, con: &AppContext) -> Command {
         self.dirty = true;
-        self.set_selected_path(self.path.clone(), con);
+        self.set_selected_path(self.source_path.clone(), con);
         Command::empty()
     }
 
@@ -255,7 +273,7 @@ impl PanelState for PreviewState {
         w.queue(cursor::MoveTo(state_area.left, 0))?;
         let mut cw = CropWriter::new(w, state_area.width as usize);
         let file_name = self
-            .path
+            .source_path
             .file_name()
             .map(|n| n.to_string_lossy().to_string())
             .unwrap_or_else(|| "???".to_string());
diff --git a/src/preview/preview_transformer.rs b/src/preview/preview_transformer.rs
new file mode 100644
index 00000000..72f144af
--- /dev/null
+++ b/src/preview/preview_transformer.rs
@@ -0,0 +1,169 @@
+use {
+    crate::{
+        errors::*,
+        preview::PreviewMode,
+    },
+    serde::Deserialize,
+    std::{
+        hash::{
+            DefaultHasher,
+            Hash,
+            Hasher,
+        },
+        path::{
+            Path,
+            PathBuf,
+        },
+        process::Command,
+    },
+    tempfile::TempDir,
+};
+
+#[derive(Debug, Clone, Copy)]
+pub struct TransformerId {
+    idx: usize,
+}
+pub struct PreviewTransformers {
+    transformers: Vec<PreviewTransformer>,
+    /// Where the output files are temporarily stored
+    temp_dir: TempDir,
+}
+#[derive(Debug, Clone, Deserialize)]
+pub struct PreviewTransformer {
+    pub input_extensions: Vec<String>,
+    pub output_extension: String,
+    /// The command generating an output file from an input file
+    /// eg "mutool draw -o {output-path} {input-path}"
+    pub command: Vec<String>,
+    pub mode: PreviewMode,
+}
+pub struct PreviewTransform {
+    pub transformer_id: TransformerId,
+    pub output_path: PathBuf,
+}
+
+impl PreviewTransformers {
+    pub fn new(transformers: &[PreviewTransformer]) -> Result<Self, ConfError> {
+        let transformers = transformers.to_vec();
+        for transformer in &transformers {
+            if transformer.command.is_empty() {
+                return Err(ConfError::MissingField {
+                    txt: "empty command in preview transformer".to_string(),
+                });
+            }
+        }
+        let temp_dir = tempfile::Builder::new()
+            .prefix("broot-conversions")
+            .tempdir()?;
+        Ok(Self {
+            transformers,
+            temp_dir,
+        })
+    }
+    pub fn transformer(
+        &self,
+        id: TransformerId,
+    ) -> &PreviewTransformer {
+        &self.transformers[id.idx]
+    }
+    pub fn transform(
+        &self,
+        input_path: &Path,
+        mode: Option<PreviewMode>,
+    ) -> Option<PreviewTransform> {
+        let transformer_id = self.find_transformer_for(input_path, mode)?;
+        let temp_dir = self.temp_dir.path();
+        match self.transformers[transformer_id.idx].transform(input_path, temp_dir) {
+            Ok(output_path) => Some(PreviewTransform {
+                transformer_id,
+                output_path,
+            }),
+            Err(e) => {
+                error!(
+                    "conversion failed using {:?}",
+                    self.transformers[transformer_id.idx].command
+                );
+                error!("conversion error: {:?}", e);
+                None
+            }
+        }
+    }
+    pub fn find_transformer_for(
+        &self,
+        path: &Path,
+        mode: Option<PreviewMode>,
+    ) -> Option<TransformerId> {
+        let extension = path.extension().and_then(|ext| ext.to_str())?;
+        for (idx, transformer) in self.transformers.iter().enumerate() {
+            if !transformer
+                .input_extensions
+                .iter()
+                .any(|ext| ext.eq_ignore_ascii_case(extension))
+            {
+                continue;
+            }
+            if let Some(mode) = mode {
+                if transformer.mode != mode {
+                    continue;
+                }
+            }
+            return Some(TransformerId { idx });
+        }
+        None
+    }
+}
+
+impl PreviewTransformer {
+    pub fn transform(
+        &self,
+        input_path: &Path,
+        temp_dir: &Path,
+    ) -> Result<PathBuf, PreviewTransformerError> {
+        let hash = {
+            let mut hasher = DefaultHasher::new();
+            input_path.hash(&mut hasher);
+            hasher.finish()
+        };
+        let output_path = temp_dir.join(format!("{:x}.{}", hash, self.output_extension,));
+        if output_path.exists() {
+            return Ok(output_path);
+        }
+
+        let explicit_input = self.command.iter().any(|c| c.contains("{input-path}"));
+        let explicit_output = self.command.iter().any(|c| c.contains("{output-path}"));
+
+        let mut command = self.command.iter().map(|part| {
+            part.replace("{input-path}", &input_path.to_string_lossy())
+                .replace("{output-path}", &output_path.to_string_lossy())
+        });
+        info!("transforming {:?} to {:?}", input_path, output_path);
+        let executable = command.next().unwrap();
+        let mut process = Command::new(executable);
+        process.stderr(std::process::Stdio::null());
+        process.args(command);
+        if !explicit_input {
+            process.stdin(std::fs::File::open(input_path)?);
+        }
+        if explicit_output {
+            process.stdout(std::process::Stdio::null());
+        } else {
+            process.stdout(std::fs::File::create(&output_path)?);
+        }
+        let exit_status = process
+            .spawn()
+            .and_then(|mut p| p.wait())?;
+        if exit_status.success() {
+            Ok(output_path)
+        } else {
+            // we remove the output file if the process failed, so that
+            // it's not returned on the next call
+            let _ = std::fs::remove_file(&output_path);
+            match exit_status.code() {
+                Some(code) => Err(PreviewTransformerError::ProcessFailed {
+                    code,
+                }),
+                None => Err(PreviewTransformerError::ProcessInterrupted),
+            }
+        }
+    }
+}
diff --git a/website/docs/conf_file.md b/website/docs/conf_file.md
index 339657c4..85bae53f 100644
--- a/website/docs/conf_file.md
+++ b/website/docs/conf_file.md
@@ -271,6 +271,72 @@ terminal_title: "{file} 🐄"
 terminal_title = "{file} 🐄"
 ```
 
+# Preview
+
+## Transformers
+
+It's possible to define transformers to apply to some files before preview.
+
+This makes it possible for example to render a specific kind of files as images, or to beautify some text ones.
+
+Below are two examples that you may adapt to your needs and prefered tools.
+
+### Render PDF using mutool:
+
+![preview pdf](img/20240706-preview-pdf.png)
+
+```Hjson
+preview_transformers: [
+    # Use mutool to render any PDF file as an image
+    # In this example we use placeholders for the input and output files
+    {
+        input_extensions: [ "pdf" ] // case doesn't matter
+        output_extension: png
+        mode: image
+        command: [ "mutool", "draw", "-w", "1000", "-o", "{output-path}", "{input-path}" ]
+    }
+]
+```
+
+### Beautify JSON using jq
+
+![preview json](img/20240706-preview-json.png)
+
+```Hjson
+preview_transformers: [
+    # Use jq to beautify JSON
+    # In this example, the command refers to neither the input nor the output,
+    # so broot pipes them to the stdin and stdout of the jq process
+    {
+        input_extensions: [ "json" ]
+        output_extension: json
+        mode: text
+        command: [ "jq" ]
+    }
+]
+```
+
+## Match surroundings
+
+You may limit the filtered view to just the matching lines:
+
+![0 line around match](img/20240706-match-surrounding-0-0.png)
+
+But if you want to have more than just the matching lines displayed in preview, you may set those parameters to
+
+```Hjson
+lines_before_match_in_preview: 1
+lines_after_match_in_preview: 1
+```
+```TOML
+lines_before_match_in_preview = 1
+lines_after_match_in_preview = 1
+```
+
+And you'll get
+
+![1 line around match](img/20240706-match-surrounding-1-1.png)
+
 # Miscellaneous
 
 ## Kitty Graphics
@@ -388,16 +454,3 @@ show_matching_characters_on_path_searches = false
 which gives this:
 
 ![not shown](img/subpath-match-not-shown.png)
-
-## Lines surrounding a match in preview
-
-If you want to have more than just the matching lines displayed in preview, you may changes those parameters in config:
-
-```Hjson
-lines_before_match_in_preview: 0
-lines_after_match_in_preview: 0
-```
-```TOML
-lines_before_match_in_preview = 0
-lines_after_match_in_preview = 0
-```
diff --git a/website/docs/img/20240706-match-surrounding-0-0.png b/website/docs/img/20240706-match-surrounding-0-0.png
new file mode 100644
index 00000000..76bf1327
Binary files /dev/null and b/website/docs/img/20240706-match-surrounding-0-0.png differ
diff --git a/website/docs/img/20240706-match-surrounding-1-1.png b/website/docs/img/20240706-match-surrounding-1-1.png
new file mode 100644
index 00000000..4cc9c3bb
Binary files /dev/null and b/website/docs/img/20240706-match-surrounding-1-1.png differ
diff --git a/website/docs/img/20240706-preview-json.png b/website/docs/img/20240706-preview-json.png
new file mode 100644
index 00000000..8d81bf2a
Binary files /dev/null and b/website/docs/img/20240706-preview-json.png differ
diff --git a/website/docs/img/20240706-preview-pdf.png b/website/docs/img/20240706-preview-pdf.png
new file mode 100644
index 00000000..b2e9d3d2
Binary files /dev/null and b/website/docs/img/20240706-preview-pdf.png differ


Test patch: