Repository: flxzt/rnote
Pull request #1377: Add thumbnailer
Author: login: tiziodcaio
Created: 2025-03-02T14:33:17Z | Merged: 2025-03-24T19:27:25Z

fixes #53
I bundled https://github.com/ayykamp/rnote-thumbnailer/blob/main/src/main.rs into the main project; needs testing
Credits: @ayykamp

Linked issue #53: .rnote file preview in file managers
![Screenshot from 2022-01-21 14-02-04](https://user-images.githubusercontent.com/32315760/150592667-eb132af5-236a-43d3-b4c5-ae26e1b5b948.png)

It would be nice to be able to see some sort of preview of .rnote files from a file manager. Would help the files be recognizable apart from just their filenames.
Linked issue comments: user: login: D0V4HKIIN
created_at: 2022-11-30T18:00:05Z
body: I have seen this being done with stl files (3d models) and it is actually pretty awesome to have
author_association: CONTRIBUTOR
user: login: zefr0x
created_at: 2024-08-25T22:24:38Z
body: We already have a CLI interface to export the content as PNG. We can use it just to export the first page to act as the thumbnail. But the problem is that its not built by default, so can we provide this functionality to `rnote-ui` as a CLI option?

The reset is easy, we just need a `.thumbnailer` file in `/usr/share/thumbnailers/` like:
```
[Thumbnailer Entry]
TryExec=/usr/bin/rnote
Exec=/usr/bin/rnote --export-thumbnail %i %o
MimeType=application/rnote;
```
*Reference: https://askubuntu.com/questions/1368910/how-to-create-custom-thumbnailers-for-nautilus-nemo-and-caja*


Also, there must be a **strict security policy** for this functionality, since if something went wrong it will be a "just look at the file arbitrary code execution vulnerability"
author_association: CONTRIBUTOR
user: login: Doublonmousse
created_at: 2024-08-26T17:11:56Z
body: We could have the thumbnail saved as a png inside of the rnote file (and having the thumbnailer be a simpler lookup to this pre existing data). We could do this upon a change of the file format.

Not sure if this has drawbacks though (this would mean a simpler logic for the thumbnailer so maybe it'd be easier to make it secure but we may have larger file sizes as a result ?)

I'd be great to have this over all platforms we currently target (that means linux, windows and mac os).

For linux, ideally this would work on most DE (so gnome and KDE), unfortunately a thumbnailer for gnome won't work for KDE. Hopefully supporting these two is enough for the rest to work on linux (??)

There may be some information to be found in other github repository that are dedicated to being thumbnailers on different OS : https://github.com/Arlorean/Voxels,  https://github.com/unlimitedbacon/stl-thumb and https://github.com/unlimitedbacon/stl-thumb-kde

author_association: COLLABORATOR
user: login: zefr0x
created_at: 2024-08-26T20:23:54Z
body: > Not sure if this has drawbacks though (this would mean a simpler logic for the thumbnailer so maybe it'd be easier to make it secure but we may have larger file sizes as a result ?)

Also, with this you will have a performance overhead every time you save your work. 
author_association: CONTRIBUTOR
user: login: ayykamp
created_at: 2024-12-10T17:49:12Z
body: I took a stab at this with [rnote-thumbnailer](https://github.com/ayykamp/rnote-thumbnailer). Should work on GNOME and KDE (haven't tested, but [seems to support thumbnailers](https://invent.kde.org/frameworks/kio/-/merge_requests/1651)). There are installation instructions in the repo.
author_association: NONE
user: login: ayykamp
created_at: 2025-01-31T16:15:46Z
body: Its also already possible to do this with the `rnote-cli`, something like:
```
[Thumbnailer Entry]
TryExec=/usr/bin/rnote
Exec=/path/to/rnote-cli  export selection -o %o all %i
MimeType=application/rnote;
```

The only thing really missing is a size arg, which most thumbnailers have (and I added in mine). Without it you only get full size exports which probably isn't ideal for thumbnails but still works.

@flxzt would you be open to a PR adding a `-s/--size` arg to `rnote-cli export`?
author_association: NONE
user: login: flxzt
created_at: 2025-01-31T16:26:11Z
body: sure, I'd be open for a PR that adds that!
author_association: OWNER

Diff:
diff --git a/Cargo.lock b/Cargo.lock
index 3121e0ddc1..55d8c444df 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3526,6 +3526,7 @@ dependencies = [
  "anyhow",
  "clap",
  "dialoguer",
+ "image",
  "indicatif",
  "nalgebra",
  "open",
diff --git a/crates/rnote-cli/Cargo.toml b/crates/rnote-cli/Cargo.toml
index 1023966892..6e02fcffec 100644
--- a/crates/rnote-cli/Cargo.toml
+++ b/crates/rnote-cli/Cargo.toml
@@ -15,6 +15,7 @@ rnote-engine = { workspace = true, features = ["cli"] }
 anyhow = { workspace = true }
 clap = { workspace = true }
 dialoguer = { workspace = true }
+image = { workspace = true }
 indicatif = { workspace = true }
 nalgebra = { workspace = true }
 open = { workspace = true }
diff --git a/crates/rnote-cli/src/cli.rs b/crates/rnote-cli/src/cli.rs
index f6b46e0031..0261ad2c8f 100644
--- a/crates/rnote-cli/src/cli.rs
+++ b/crates/rnote-cli/src/cli.rs
@@ -1,5 +1,5 @@
 // Imports
-use crate::{export, import, test};
+use crate::{export, import, test, thumbnail};
 use anyhow::Context;
 use clap::Parser;
 use rnote_compose::SplitOrder;
@@ -69,6 +69,16 @@ pub(crate) enum Command {
         #[arg(long, action = clap::ArgAction::SetTrue, global = true)]
         open: bool,
     },
+    /// Generate rnote thumbail from a given file
+    Thumbnail {
+        /// Input rnote file
+        rnote_file: PathBuf,
+        /// Size of the thumbnail in bits
+        #[arg(short, long, default_value_t = 256)]
+        size: u32,
+        /// Output path of the thumbnail
+        output: PathBuf,
+    },
 }
 
 #[derive(clap::ValueEnum, Debug, Clone, Copy, Default)]
@@ -242,6 +252,14 @@ pub(crate) async fn run() -> anyhow::Result<()> {
             .await?;
             println!("Export finished!");
         }
+        Command::Thumbnail {
+            rnote_file,
+            size,
+            output,
+        } => {
+            println!("Thumbnail...");
+            thumbnail::run_thumbnail(rnote_file, size, output).await?;
+        }
     }
 
     Ok(())
diff --git a/crates/rnote-cli/src/main.rs b/crates/rnote-cli/src/main.rs
index 0a6c875cf6..eb2c13feaa 100644
--- a/crates/rnote-cli/src/main.rs
+++ b/crates/rnote-cli/src/main.rs
@@ -7,6 +7,7 @@ pub(crate) mod cli;
 pub(crate) mod export;
 pub(crate) mod import;
 pub(crate) mod test;
+pub(crate) mod thumbnail;
 pub(crate) mod validators;
 
 // Renames
diff --git a/crates/rnote-cli/src/thumbnail.rs b/crates/rnote-cli/src/thumbnail.rs
new file mode 100644
index 0000000000..e15ccc80c0
--- /dev/null
+++ b/crates/rnote-cli/src/thumbnail.rs
@@ -0,0 +1,62 @@
+// Inspired by: https://github.com/ayykamp/rnote-thumbnailer/blob/main/src/main.rs
+// Author: ayykamp <kamp@ayyy.dev>
+
+use anyhow::Context;
+use image::DynamicImage;
+use image::imageops::FilterType;
+use rnote_engine::Engine;
+use rnote_engine::engine::EngineSnapshot;
+use rnote_engine::engine::export::{SelectionExportFormat, SelectionExportPrefs};
+use std::fs::File;
+use std::io::Read;
+use std::path::PathBuf;
+
+pub(crate) async fn run_thumbnail(
+    rnote_file: PathBuf,
+    output_size: u32,
+    output: PathBuf,
+) -> anyhow::Result<()> {
+    let mut engine = Engine::default();
+    let mut rnote_file_bytes = vec![];
+
+    let mut fh = File::open(rnote_file)?;
+    fh.read_to_end(&mut rnote_file_bytes)?;
+    let engine_snapshot = EngineSnapshot::load_from_rnote_bytes(rnote_file_bytes).await?;
+
+    // We dont care about the return values of these functions
+    let _ = engine.load_snapshot(engine_snapshot);
+    let _ = engine.select_all_strokes();
+
+    let prefs = SelectionExportPrefs {
+        export_format: SelectionExportFormat::Png,
+        ..Default::default()
+    };
+    let export_bytes = engine
+        .export_selection(Some(prefs))
+        .await??
+        .context("Exporting selection failed, no strokes selected.")?;
+
+    let mut image = image::load_from_memory(&export_bytes)?;
+    let (width, height) = (image.width(), image.height());
+
+    if std::cmp::max(width, height) > output_size {
+        let ratio = if width >= height {
+            // Landscape
+            width as f64 / output_size as f64
+        } else {
+            // Portrait
+            height as f64 / output_size as f64
+        };
+        let nwidth = width as f64 / ratio;
+        let nheight = height as f64 / ratio;
+        image = DynamicImage::from(image::imageops::resize(
+            &image,
+            nwidth as u32,
+            nheight as u32,
+            FilterType::Nearest,
+        ));
+    }
+
+    image.save(output)?;
+    Ok(())
+}
diff --git a/crates/rnote-ui/data/meson.build b/crates/rnote-ui/data/meson.build
index a187fba6f8..d877e4070c 100644
--- a/crates/rnote-ui/data/meson.build
+++ b/crates/rnote-ui/data/meson.build
@@ -165,6 +165,11 @@ configure_file(
 #   install_dir: datadir / 'dbus-1' / 'services',
 # )
 
+# Thumbnail
+if build_cli
+    install_data('rnote.thumbnailer', install_dir: datadir / 'thumbnailers')
+endif
+
 rnote_ui_gresources_icons_files = files(
     'icons/scalable/actions/add-page-symbolic.svg',
     'icons/scalable/actions/appwindow-fullscreen-symbolic.svg',
diff --git a/crates/rnote-ui/data/rnote.thumbnailer b/crates/rnote-ui/data/rnote.thumbnailer
new file mode 100644
index 0000000000..120d056c15
--- /dev/null
+++ b/crates/rnote-ui/data/rnote.thumbnailer
@@ -0,0 +1,4 @@
+[Thumbnailer Entry]
+TryExec=rnote-cli thumbnail
+Exec=rnote-cli thumbnail %i %o -s %s
+MimeType=application/rnote
\ No newline at end of file


Reviews:
user: login: flxzt
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-16T20:53:48Z
user: login: flxzt
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-16T20:57:56Z
user: login: flxzt
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-16T20:59:14Z
user: login: tiziodcaio
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T22:55:31Z
user: login: tiziodcaio
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-16T22:56:07Z
user: login: flxzt
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-24T09:18:04Z
user: login: flxzt
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-24T09:20:12Z
user: login: tiziodcaio
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T11:45:22Z
user: login: tiziodcaio
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T12:12:01Z

Review comments:
diff_hunk: @@ -69,6 +69,13 @@ pub(crate) enum Command {
         #[arg(long, action = clap::ArgAction::SetTrue, global = true)]
         open: bool,
     },
+    /// Generate rnote thumbail from a given file
+    Thumbnail {
+        rnote_file: PathBuf,
+        #[arg(short, long, default_value_t = 256)]
+        size: u32,
+        output: std::ffi::OsString,
path: crates/rnote-cli/src/cli.rs
user: login: flxzt
body: why is this a string, shouldn't this be a `PathBuf`?
created_at: 2025-03-16T20:53:48Z
author_association: OWNER
diff_hunk: @@ -377,6 +379,8 @@ rnote_ui_gresources_ui_files = files(
     'ui/style.css',
     'ui/unitentry.ui',
     'ui/workspacebrowser.ui',
+    'ui/workspacesbar/workspacerow.ui',
+    'ui/workspacesbar/workspacesbar.ui',
path: crates/rnote-ui/data/meson.build
user: login: flxzt
body: I don't think this MR should need to touch this list. I ordered them so that folders are before files.
created_at: 2025-03-16T20:57:56Z
author_association: OWNER
diff_hunk: @@ -377,6 +379,8 @@ rnote_ui_gresources_ui_files = files(
     'ui/style.css',
     'ui/unitentry.ui',
     'ui/workspacebrowser.ui',
+    'ui/workspacesbar/workspacerow.ui',
+    'ui/workspacesbar/workspacesbar.ui',
path: crates/rnote-ui/data/meson.build
user: login: flxzt
body: unless you have a formatter for meson? I'd happily apply a standard formatting if I can run it myself (and maybe add it to the fmt check in CI)
created_at: 2025-03-16T20:59:14Z
author_association: OWNER
diff_hunk: @@ -377,6 +379,8 @@ rnote_ui_gresources_ui_files = files(
     'ui/style.css',
     'ui/unitentry.ui',
     'ui/workspacebrowser.ui',
+    'ui/workspacesbar/workspacerow.ui',
+    'ui/workspacesbar/workspacesbar.ui',
path: crates/rnote-ui/data/meson.build
user: login: tiziodcaio
body: It was meson LSP formatter; if you prefer I can discard these changes :]
created_at: 2025-03-16T22:55:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -69,6 +69,13 @@ pub(crate) enum Command {
         #[arg(long, action = clap::ArgAction::SetTrue, global = true)]
         open: bool,
     },
+    /// Generate rnote thumbail from a given file
+    Thumbnail {
+        rnote_file: PathBuf,
+        #[arg(short, long, default_value_t = 256)]
+        size: u32,
+        output: std::ffi::OsString,
path: crates/rnote-cli/src/cli.rs
user: login: tiziodcaio
body: Oh maybe! I'll review ASAP thx
created_at: 2025-03-16T22:56:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -377,6 +379,8 @@ rnote_ui_gresources_ui_files = files(
     'ui/style.css',
     'ui/unitentry.ui',
     'ui/workspacebrowser.ui',
+    'ui/workspacesbar/workspacerow.ui',
+    'ui/workspacesbar/workspacesbar.ui',
path: crates/rnote-ui/data/meson.build
user: login: flxzt
body: I'll format the meson files separately, so that this PR is not cluttered with these formatting changes.
created_at: 2025-03-24T09:18:04Z
author_association: OWNER
diff_hunk: @@ -69,6 +69,13 @@ pub(crate) enum Command {
         #[arg(long, action = clap::ArgAction::SetTrue, global = true)]
         open: bool,
     },
+    /// Generate rnote thumbail from a given file
+    Thumbnail {
+        rnote_file: PathBuf,
+        #[arg(short, long, default_value_t = 256)]
+        size: u32,
+        output: PathBuf,
path: crates/rnote-cli/src/cli.rs
user: login: flxzt
body: Documentation comments explaining these arguments would be nice.
created_at: 2025-03-24T09:20:11Z
author_association: OWNER
diff_hunk: @@ -377,6 +379,8 @@ rnote_ui_gresources_ui_files = files(
     'ui/style.css',
     'ui/unitentry.ui',
     'ui/workspacebrowser.ui',
+    'ui/workspacesbar/workspacerow.ui',
+    'ui/workspacesbar/workspacesbar.ui',
path: crates/rnote-ui/data/meson.build
user: login: tiziodcaio
body: ok, I'll rebase as soon as I seen the commit
created_at: 2025-03-24T11:45:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -377,6 +379,8 @@ rnote_ui_gresources_ui_files = files(
     'ui/style.css',
     'ui/unitentry.ui',
     'ui/workspacebrowser.ui',
+    'ui/workspacesbar/workspacerow.ui',
+    'ui/workspacesbar/workspacesbar.ui',
path: crates/rnote-ui/data/meson.build
user: login: tiziodcaio
body: Or maybe let's do this: I edit the history of the feature branch ASAP so there's no problem
created_at: 2025-03-24T12:12:01Z
author_association: CONTRIBUTOR

PR comments:
user: login: tiziodcaio
created_at: 2025-03-02T18:20:03Z
body: Tested on local (Arch Linux) with Dolphin, Nautilus, Nemo and Thunar.
Here the package build locally from `AUR`'s `rnote-git` [rnote.zip](https://github.com/user-attachments/files/19044227/rnote.zip)

PKGBUILD:

```sh
# Maintainer: Jingu <xiuluo dot android at gmail dot com>

# Contributor: Igor Dyatlov <dyatlov.igor@protonmail.com>

pkgname=rnote-git
pkgver=0.6.0.r4.g13a5c565
pkgrel=1
pkgdesc="A simple drawing application to create handwritten notes"
arch=('x86_64')
url="https://github.com/tiziodcaio/rnote"
license=('GPL3')
depends=('gtk4' 'glib2' 'libadwaita' 'poppler-glib' 'gstreamer' 'alsa-lib')
makedepends=('meson' 'cargo' 'cmake' 'clang' 'git')
provides=("${pkgname%-git}")
conflicts=("${pkgname%-git}")
source=("${pkgname%-git}::git+$url.git")
b2sums=('SKIP')
options=('!lto')

prepare() {
  cd "${pkgname%-git}"
  export RUSTUP_TOOLCHAIN=stable
  cargo fetch --locked --target "$CARCH-unknown-linux-gnu"
}

# pkgver() {
#   cd "${pkgname%-git}"
#   git describe --long --tags | sed 's/^v//;s/\([^-]*-g\)/r\1/;s/-/./g'
# }

build() {
  export RUSTUP_TOOLCHAIN=stable
  arch-meson "${pkgname%-git}" build -Dcli=true
  meson compile -C build
}

check() {
  meson test -C build --print-errorlogs || :
}

package() {
  meson install -C build --destdir "$pkgdir"
}

```
author_association: CONTRIBUTOR
user: login: Doublonmousse
created_at: 2025-03-15T21:58:46Z
body: Can you rerun `cargo fmt` for the fmt check to pass ? There may be changes in the accepted fmt format after https://github.com/flxzt/rnote/commit/0fcb985a253ccd2e37c0fd198622cc132ffd8c9c with the change to rust 2024 edition
author_association: COLLABORATOR
user: login: flxzt
created_at: 2025-03-16T08:54:17Z
body: Do you agree that your thumbnailer gets included in Rnote and will be licensed under GPLv3? @ayykamp
author_association: OWNER
user: login: ayykamp
created_at: 2025-03-16T13:28:40Z
body: Yes, no problem.
author_association: NONE
user: login: flxzt
created_at: 2025-03-16T21:04:52Z
body: looks good otherwise, I just want to take a look at the XDG thumbnail specification before merging. Unfortunately the freedesktop site (https://specifications.freedesktop.org/thumbnail-spec/thumbnail-spec-latest.html) currently (2025-03-16) is under maintenance (until 2025-03-22). I'll look at it as soon as it's available again
author_association: OWNER
user: login: Kneemund
created_at: 2025-03-16T22:23:52Z
body: > Unfortunately the freedesktop site (https://specifications.freedesktop.org/thumbnail-spec/thumbnail-spec-latest.html) currently (2025-03-16) is under maintenance (until 2025-03-22). I'll look at it as soon as it's available again


Most pages seem to be covered by the wayback machine, e.g. the page you linked is viewable at https://web.archive.org/web/20240812021837/https://specifications.freedesktop.org/thumbnail-spec/latest/index.html (if you don't mind the fairly long loading times)
author_association: COLLABORATOR
user: login: flxzt
created_at: 2025-03-24T19:27:08Z
body: looks good, thanks a lot!
author_association: OWNER