Repository: flxzt/rnote (language: rust)

Issue:
Rnote_cli: export only non-blank area
**Is your feature request related to a problem? Please describe.**  
If I export a document using this command: `rnote_cli export --output-file test.svg test.rnote` I get ![the following output.](https://github.com/flxzt/rnote/assets/37816362/c370cf90-b245-4d4b-84f2-aa66094b5820)

As you can see, it includes the whole page which can be a waste of visiblespace when one tries to attach this somewhere. 
 
**Describe the solution you'd like**  
The application already provides a "export selection" feature. It would be useful to add a flag in the rnote_cli tool that allows to export the smallest area that includes all the points in the document, also excluding any kind of background (like the dots in the picture above).



Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index dcc75913f4..216e0e07e4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1015,6 +1015,18 @@ dependencies = [
  "syn 1.0.109",
 ]
 
+[[package]]
+name = "dialoguer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59c6f2989294b9a498d3ad5491a79c6deb604617378e1cdc4bfc1c1361fe2f87"
+dependencies = [
+ "console",
+ "shell-words",
+ "tempfile",
+ "zeroize",
+]
+
 [[package]]
 name = "downcast-rs"
 version = "1.2.0"
@@ -1975,6 +1987,15 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "is-docker"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
+dependencies = [
+ "once_cell",
+]
+
 [[package]]
 name = "is-terminal"
 version = "0.4.9"
@@ -1986,6 +2007,16 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "is-wsl"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
+dependencies = [
+ "is-docker",
+ "once_cell",
+]
+
 [[package]]
 name = "is_ci"
 version = "1.1.1"
@@ -2624,6 +2655,17 @@ version = "1.18.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
 
+[[package]]
+name = "open"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfabf1927dce4d6fdf563d63328a0a506101ced3ec780ca2135747336c98cef8"
+dependencies = [
+ "is-wsl",
+ "libc",
+ "pathdiff",
+]
+
 [[package]]
 name = "optional"
 version = "0.5.0"
@@ -2773,6 +2815,12 @@ version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42"
 
+[[package]]
+name = "pathdiff"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd"
+
 [[package]]
 name = "peeking_take_while"
 version = "0.1.2"
@@ -3393,9 +3441,13 @@ name = "rnote-cli"
 version = "0.7.1"
 dependencies = [
  "anyhow",
+ "atty",
  "clap",
+ "dialoguer",
  "indicatif",
  "log",
+ "open",
+ "parry2d-f64",
  "rnote-compose",
  "rnote-engine",
  "smol",
@@ -3407,6 +3459,7 @@ version = "0.7.1"
 dependencies = [
  "anyhow",
  "base64",
+ "clap",
  "ink-stroke-modeler-rs",
  "kurbo",
  "log",
@@ -3436,6 +3489,7 @@ dependencies = [
  "base64",
  "cairo-rs",
  "chrono",
+ "clap",
  "flate2",
  "futures",
  "geo",
@@ -3762,6 +3816,12 @@ dependencies = [
  "stable_deref_trait",
 ]
 
+[[package]]
+name = "shell-words"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
+
 [[package]]
 name = "shlex"
 version = "1.1.0"
@@ -4895,6 +4955,12 @@ version = "0.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
 
+[[package]]
+name = "zeroize"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9"
+
 [[package]]
 name = "zune-inflate"
 version = "0.2.54"
diff --git a/Cargo.toml b/Cargo.toml
index e0aeeca554..711f9c359d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -73,6 +73,9 @@ rsvg = { git = "https://gitlab.gnome.org/GNOME/librsvg", rev = "2b376c24d9b6718b
 poppler-rs = { version = "0.22", features = ["v20_9"] }
 gtk4 = { version = "0.7.1", features = ["v4_10"] }
 adw = { version = "0.5.2", package="libadwaita", features = ["v1_3"] }
+dialoguer = "0.10.4"
+open = "5"
+atty = "0.2"
 
 [patch.crates-io]
 # once a new piet (current v0.6.2) is released with the updated cairo, this can be removed
diff --git a/crates/rnote-cli/Cargo.toml b/crates/rnote-cli/Cargo.toml
index 07524a7897..a9728b4cd6 100644
--- a/crates/rnote-cli/Cargo.toml
+++ b/crates/rnote-cli/Cargo.toml
@@ -9,11 +9,15 @@ homepage.workspace = true
 repository.workspace = true
 
 [dependencies]
-rnote-compose = { workspace = true }
-rnote-engine = { workspace = true }
+rnote-compose = { workspace = true, features = ["clap-derive"] }
+rnote-engine = { workspace = true, features = ["clap-derive"] }
 
 log = { workspace = true }
 anyhow = { workspace = true }
 smol = { workspace = true }
 clap = { workspace = true }
 indicatif = { workspace = true }
+dialoguer = { workspace = true }
+open = { workspace = true }
+parry2d-f64 = { workspace = true }
+atty = { workspace = true }
diff --git a/crates/rnote-cli/src/cli.rs b/crates/rnote-cli/src/cli.rs
index 2dd49ff6dd..3835b6d485 100644
--- a/crates/rnote-cli/src/cli.rs
+++ b/crates/rnote-cli/src/cli.rs
@@ -1,16 +1,18 @@
-use clap::{Parser, Subcommand};
-use rnote_engine::engine::export::{DocExportFormat, DocExportPrefs};
-use rnote_engine::engine::EngineSnapshot;
+use clap::{ArgAction, Parser, Subcommand, ValueEnum};
+use rnote_engine::engine::{import::XoppImportPrefs, EngineSnapshot};
 use rnote_engine::RnoteEngine;
 use smol::fs::File;
 use smol::io::{AsyncReadExt, AsyncWriteExt};
-use std::path::{Path, PathBuf};
+use std::path::PathBuf;
 use std::time::Duration;
 
+use crate::export::{run_export, ExportCommands};
+use crate::validators;
+
 ///    rnote-cli  Copyright (C) 2023  The Rnote Authors{n}{n}
 ///    This program is free software; you can redistribute it and/or modify it under the terms of the GPL v3 or (at your option) any later version.
 #[derive(Parser)]
-#[command(author, version, about, long_about = None)]
+#[command(author, version, about, long_about = None, arg_required_else_help = true)]
 pub(crate) struct Cli {
     #[command(subcommand)]
     pub(crate) command: Commands,
@@ -18,7 +20,7 @@ pub(crate) struct Cli {
 
 #[derive(Subcommand)]
 pub(crate) enum Commands {
-    /// Tests if the specified files can be opened and are valid rnote files.
+    /// Tests if the specified files can be opened and are valid rnote files
     Test {
         /// the rnote files
         rnote_files: Vec<PathBuf>,
@@ -32,37 +34,69 @@ pub(crate) enum Commands {
         #[arg(short = 'i', long)]
         input_file: PathBuf,
         /// When importing a .xopp file, the import dpi can be specified.{n}
-        /// Else the default (96) is used.
-        #[arg(long)]
-        xopp_dpi: Option<f64>,
+        #[arg(long, default_value_t = XoppImportPrefs::default().dpi)]
+        xopp_dpi: f64,
     },
     /// Exports the Rnote file(s) and saves it in the desired format.{n}
-    /// When using --output-file, only one input file can be given.{n}
-    /// The export format is recognized from the file extension of the output file.{n}
-    /// When using --output-format, the same file name is used with the extension changed.{n}
-    /// --output-file and --output-format are mutually exclusive but one of them is required.{n}
-    /// Currently `.svg`, `.xopp` and `.pdf` are supported.{n}
-    /// Usages: {n}
-    /// rnote-cli export --output-file [filename.(svg|xopp|pdf)] [1 file]{n}
-    /// rnote-cli export --output-format [svg|xopp|pdf] [list of files]
+    /// See subcommands for usage
     Export {
         /// the rnote save file
+        #[arg(global = true)]
         rnote_files: Vec<PathBuf>,
-        /// the export output file. Only allows for one input file. Exclusive with output-format.
-        #[arg(short = 'o', long, conflicts_with("output_format"), required(true))]
-        output_file: Option<PathBuf>,
-        /// the export output format. Exclusive with output-file.
-        #[arg(short = 'f', long, conflicts_with("output_file"), required(true))]
-        output_format: Option<String>,
-        /// export with background
-        #[arg(short = 'b', long)]
-        with_background: Option<bool>,
-        /// export with background pattern
-        #[arg(short = 'p', long)]
-        with_pattern: Option<bool>,
+        /// The action that will be performed if the to be exported file(s) already exist(s)
+        #[arg(long, default_value = "ask", global = true)]
+        on_conflict: OnConflict,
+        /// export without background
+        #[arg(short = 'b', long, action = ArgAction::SetTrue, global = true)]
+        no_background: bool,
+        /// export without background pattern
+        #[arg(short = 'p', long, action = ArgAction::SetTrue, global = true)]
+        no_pattern: bool,
+        /// Inspect result after the export is finished. Open output folder when using doc-pages
+        #[arg(long, action = ArgAction::SetTrue, global = true)]
+        open: bool,
+        #[command(subcommand)]
+        export_command: ExportCommands,
     },
 }
 
+#[derive(ValueEnum, Copy, Clone, Debug, Default)]
+pub(crate) enum OnConflict {
+    #[default]
+    /// Ask before Overwriting
+    Ask,
+    /// Overwrite Files
+    Overwrite,
+    #[value(skip)]
+    AlwaysOverwrite,
+    /// Skip current Export
+    Skip,
+    #[value(skip)]
+    AlwaysSkip,
+    /// Add number to the end of the file
+    Suffix,
+    #[value(skip)]
+    AlwaysSuffix,
+}
+
+impl std::fmt::Display for OnConflict {
+    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
+        write!(
+            f,
+            "{}",
+            match self {
+                Self::Ask => "Open existing file for inspection and ask again",
+                Self::Overwrite => "Overwrite existing file",
+                Self::AlwaysOverwrite => "Always overwrite existing files",
+                Self::Skip => "Skip file",
+                Self::AlwaysSkip => "Always skip file",
+                Self::Suffix => "Append number at the end of the file name",
+                Self::AlwaysSuffix => "Always append number at the end of the file name",
+            }
+        )
+    }
+}
+
 pub(crate) async fn run() -> anyhow::Result<()> {
     let mut engine = RnoteEngine::default();
 
@@ -73,12 +107,9 @@ pub(crate) async fn run() -> anyhow::Result<()> {
             println!("Testing..");
 
             for rnote_file in rnote_files.into_iter() {
+                validators::file_has_ext(&rnote_file, "rnote")?;
                 let file_disp = rnote_file.display().to_string();
-                let pb = indicatif::ProgressBar::new_spinner();
-                pb.set_draw_target(indicatif::ProgressDrawTarget::stdout());
-                pb.set_message(format!("Testing file \"{file_disp}\""));
-                pb.enable_steady_tick(Duration::from_millis(8));
-
+                let pb = new_pb(format!("Testing file \"{file_disp}\""));
                 // test
                 if let Err(e) = test_file(&mut engine, rnote_file).await {
                     let msg = format!("Test failed, Err: {e:?}");
@@ -103,21 +134,19 @@ pub(crate) async fn run() -> anyhow::Result<()> {
             input_file,
             xopp_dpi,
         } => {
+            validators::file_has_ext(&rnote_file, "rnote")?;
+            // xopp files dont require file extensions
+            validators::path_is_file(&input_file)?;
             println!("Importing..");
 
             // apply given arguments to import prefs
-            if let Some(xopp_dpi) = xopp_dpi {
-                engine.import_prefs.xopp_import_prefs.dpi = xopp_dpi;
-            }
+            engine.import_prefs.xopp_import_prefs.dpi = xopp_dpi;
 
             let rnote_file_disp = rnote_file.display().to_string();
             let input_file_disp = input_file.display().to_string();
-            let pb = indicatif::ProgressBar::new_spinner().with_message(format!(
+            let pb = new_pb(format!(
                 "Importing \"{input_file_disp}\" to: \"{rnote_file_disp}\""
             ));
-            pb.set_draw_target(indicatif::ProgressDrawTarget::stdout());
-            pb.enable_steady_tick(Duration::from_millis(8));
-
             // import
             if let Err(e) = import_file(&mut engine, input_file, rnote_file).await {
                 let msg = format!(
@@ -141,111 +170,36 @@ pub(crate) async fn run() -> anyhow::Result<()> {
         }
         Commands::Export {
             rnote_files,
-            output_file,
-            output_format,
-            with_background,
-            with_pattern,
+            no_background,
+            no_pattern,
+            on_conflict,
+            open,
+            export_command,
         } => {
             println!("Exporting..");
-
-            // apply given arguments to export prefs
-            engine.export_prefs.doc_export_prefs = create_doc_export_prefs_from_args(
-                output_file.as_deref(),
-                output_format.as_deref(),
-                with_background,
-                with_pattern,
-            )?;
-
-            match output_file {
-                Some(ref output_file) => match rnote_files.get(0) {
-                    Some(rnote_file) => {
-                        if rnote_files.len() > 1 {
-                            return Err(anyhow::anyhow!("Was expecting only 1 file. Use --output-format when exporting multiple files."));
-                        }
-
-                        let rnote_file_disp = rnote_file.display().to_string();
-                        let output_file_disp = output_file.display().to_string();
-                        let pb = indicatif::ProgressBar::new_spinner().with_message(format!(
-                            "Exporting \"{rnote_file_disp}\" to: \"{output_file_disp}\""
-                        ));
-                        pb.set_draw_target(indicatif::ProgressDrawTarget::stdout());
-                        pb.enable_steady_tick(Duration::from_millis(8));
-
-                        // export
-                        if let Err(e) = export_to_file(&mut engine, rnote_file, output_file).await {
-                            let msg = format!("Export \"{rnote_file_disp}\" to: \"{output_file_disp}\" failed, Err {e:?}");
-                            if pb.is_hidden() {
-                                println!("{msg}")
-                            }
-                            pb.abandon_with_message(msg);
-                            return Err(e);
-                        } else {
-                            let msg = format!(
-                                "Export \"{rnote_file_disp}\" to: \"{output_file_disp}\" succeeded"
-                            );
-                            if pb.is_hidden() {
-                                println!("{msg}")
-                            }
-                            pb.finish_with_message(msg);
-                        }
-                    }
-                    None => return Err(anyhow::anyhow!("Failed to get filename from rnote_files")),
-                },
-                None => {
-                    let output_files = rnote_files
-                        .iter()
-                        .map(|file| {
-                            let mut output = file.clone();
-                            output.set_extension(
-                                engine
-                                    .export_prefs
-                                    .doc_export_prefs
-                                    .export_format
-                                    .file_ext(),
-                            );
-                            output
-                        })
-                        .collect::<Vec<PathBuf>>();
-
-                    for (rnote_file, output_file) in rnote_files.iter().zip(output_files.iter()) {
-                        let rnote_file_disp = rnote_file.display().to_string();
-                        let output_file_disp = output_file.display().to_string();
-                        let pb = indicatif::ProgressBar::new_spinner();
-                        pb.set_draw_target(indicatif::ProgressDrawTarget::stdout());
-                        pb.set_message(format!(
-                            "Exporting \"{rnote_file_disp}\" to: \"{output_file_disp}\""
-                        ));
-                        pb.enable_steady_tick(Duration::from_millis(8));
-
-                        // export
-                        if let Err(e) = export_to_file(&mut engine, &rnote_file, &output_file).await
-                        {
-                            let msg = format!("Export \"{rnote_file_disp}\" to: \"{output_file_disp}\" failed, Err {e:?}");
-                            if pb.is_hidden() {
-                                println!("{msg}")
-                            }
-                            pb.abandon_with_message(msg);
-                            return Err(e);
-                        } else {
-                            let msg = format!(
-                                "Export \"{rnote_file_disp}\" to: \"{output_file_disp}\" succeeded"
-                            );
-                            if pb.is_hidden() {
-                                println!("{msg}")
-                            }
-                            pb.finish_with_message(msg);
-                        }
-                    }
-                }
-            }
-
-            println!("Export Finished!");
+            run_export(
+                export_command,
+                &mut engine,
+                rnote_files,
+                no_background,
+                no_pattern,
+                on_conflict,
+                open,
+            )
+            .await?
         }
     }
 
     Ok(())
 }
 
+pub(crate) fn new_pb(message: String) -> indicatif::ProgressBar {
+    let pb = indicatif::ProgressBar::new_spinner().with_message(message);
+    pb.set_draw_target(indicatif::ProgressDrawTarget::stdout());
+    pb.enable_steady_tick(Duration::from_millis(8));
+    pb
+}
+
 pub(crate) async fn test_file(
     _engine: &mut RnoteEngine,
     rnote_file: PathBuf,
@@ -288,87 +242,3 @@ pub(crate) async fn import_file(
 
     Ok(())
 }
-
-fn get_export_format(format: &str) -> anyhow::Result<DocExportFormat> {
-    match format {
-        "svg" => Ok(DocExportFormat::Svg),
-        "xopp" => Ok(DocExportFormat::Xopp),
-        "pdf" => Ok(DocExportFormat::Pdf),
-        ext => Err(anyhow::anyhow!(
-            "Could not create doc export prefs, unsupported export file extension `{ext}`"
-        )),
-    }
-}
-
-pub(crate) fn create_doc_export_prefs_from_args(
-    output_file: Option<impl AsRef<Path>>,
-    output_format: Option<&str>,
-    with_background: Option<bool>,
-    with_pattern: Option<bool>,
-) -> anyhow::Result<DocExportPrefs> {
-    let format = match (output_file, output_format) {
-        (Some(file), None) => match file.as_ref().extension().and_then(|ext| ext.to_str()) {
-            Some(extension) => get_export_format(extension),
-            None => {
-                return Err(anyhow::anyhow!(
-                    "Output file needs to have an extension to determine the file type"
-                ))
-            }
-        },
-        (None, Some(out_format)) => get_export_format(out_format),
-        // unreachable because they are exclusive (conflicts_with)
-        (Some(_), Some(_)) => {
-            return Err(anyhow::anyhow!(
-                "--output-file and --output-format are mutually exclusive."
-            ))
-        }
-        // unreachable because they are required
-        (None, None) => {
-            return Err(anyhow::anyhow!(
-                "--output-file or --output-format is required."
-            ))
-        }
-    }?;
-
-    let mut prefs = DocExportPrefs {
-        export_format: format,
-        ..Default::default()
-    };
-
-    if let Some(with_background) = with_background {
-        prefs.with_background = with_background;
-    }
-    if let Some(with_pattern) = with_pattern {
-        prefs.with_pattern = with_pattern;
-    }
-
-    Ok(prefs)
-}
-
-pub(crate) async fn export_to_file(
-    engine: &mut RnoteEngine,
-    rnote_file: impl AsRef<Path>,
-    output_file: impl AsRef<Path>,
-) -> anyhow::Result<()> {
-    let Some(export_file_name) = output_file.as_ref().file_name().map(|s| s.to_string_lossy().to_string()) else {
-        return Err(anyhow::anyhow!("Failed to get filename from output_file"));
-    };
-
-    let mut rnote_bytes = vec![];
-    File::open(rnote_file)
-        .await?
-        .read_to_end(&mut rnote_bytes)
-        .await?;
-
-    let engine_snapshot = EngineSnapshot::load_from_rnote_bytes(rnote_bytes).await?;
-    let _ = engine.load_snapshot(engine_snapshot);
-
-    // We applied the prefs previously to the engine
-    let export_bytes = engine.export_doc(export_file_name, None).await??;
-
-    let mut fh = File::create(output_file).await?;
-    fh.write_all(&export_bytes).await?;
-    fh.sync_all().await?;
-
-    Ok(())
-}
diff --git a/crates/rnote-cli/src/export.rs b/crates/rnote-cli/src/export.rs
new file mode 100644
index 0000000000..077cd3f80a
--- /dev/null
+++ b/crates/rnote-cli/src/export.rs
@@ -0,0 +1,744 @@
+use std::path::{Path, PathBuf};
+
+use anyhow::Context;
+use clap::{Args, Subcommand, ValueEnum};
+use parry2d_f64::{bounding_volume::Aabb, na::Vector2};
+use rnote_compose::SplitOrder;
+use rnote_engine::{
+    engine::{
+        export::{
+            DocExportFormat, DocExportPrefs, DocPagesExportFormat, DocPagesExportPrefs,
+            SelectionExportFormat, SelectionExportPrefs,
+        },
+        EngineSnapshot,
+    },
+    RnoteEngine, SelectionCollision,
+};
+use smol::{
+    fs::File,
+    io::{AsyncReadExt, AsyncWriteExt},
+};
+
+use crate::cli::{new_pb, OnConflict};
+use crate::validators;
+
+#[derive(Subcommand, Debug)]
+pub(crate) enum ExportCommands {
+    /// Export the entire document.{n}
+    /// When using --output-file, only one input file can be given.{n}
+    /// The export format is recognized from the file extension of the output file.{n}
+    /// When using --output-format, the file name and path of the rnote file is used with the extension changed.{n}
+    /// --output-file and --output-format are mutually exclusive but one of them is required.{n}
+    Doc {
+        #[command(flatten)]
+        file_args: FileArgs<DocExportFormat>,
+        /// The page order when documents with layouts that expand in horizontal and vertical directions are cut into pages.
+        #[arg(long, default_value_t = Default::default())]
+        page_order: SplitOrder,
+    },
+    /// Export each page of the documents individually. Alias: pages{n}
+    /// Both --output-dir and --output-format need to be set.{n}
+    #[command(alias = "pages")]
+    DocPages {
+        /// the directory the pages get exported to
+        #[arg(short = 'o', long)]
+        output_dir: PathBuf,
+        /// The directory the pages get exported to.{n}
+        /// Uses file stem of input file if not set.{n}
+        /// Cannot be used when exporting multiple files
+        #[arg(short = 's', long)]
+        output_file_stem: Option<String>,
+        /// the export output format
+        #[arg(short = 'f', long)]
+        output_format: DocPagesExportFormat,
+        /// The page order when documents with layouts that expand in horizontal and vertical directions are cut into pages
+        #[arg(long, default_value_t = Default::default())]
+        page_order: SplitOrder,
+        /// bitmap scale factor in relation to the actual size on the document
+        #[arg(long, default_value_t = DocPagesExportPrefs::default().bitmap_scalefactor)]
+        bitmap_scalefactor: f64,
+        /// quality of the jpeg image
+        #[arg(long, default_value_t = DocPagesExportPrefs::default().jpeg_quality)]
+        jpeg_quality: u8,
+    },
+    /// Export a selection in a document. Alias: sel{n}
+    /// When using --output-file, only one input file can be given.{n}
+    /// The export format is recognized from the file extension of the output file.{n}
+    /// When using --output-format, the same file name is used with the extension changed.{n}
+    /// --output-file and --output-format are mutually exclusive but one of them is required.{n}
+    /// When not selecting all, you can use --selection-collision to switch between contains and intersects collision
+    #[command(alias = "sel")]
+    Selection {
+        #[command(flatten)]
+        file_args: FileArgs<SelectionExportFormat>,
+        #[command(subcommand)]
+        selection: SelectionCommands,
+        #[arg(short = 'c', long, default_value_t = Default::default(), global = true)]
+        /// if the strokes inside or intersecting with the given bounds are exported. Ignored when using all
+        selection_collision: SelectionCollision,
+        /// bitmap scale factor in relation to the actual size on the document
+        #[arg(long, default_value_t = SelectionExportPrefs::default().bitmap_scalefactor, global = true)]
+        bitmap_scalefactor: f64,
+        /// quality of the jpeg image
+        #[arg(long, default_value_t = SelectionExportPrefs::default().jpeg_quality, global = true)]
+        jpeg_quality: u8,
+        /// margin around the document
+        #[arg(long, default_value_t = SelectionExportPrefs::default().margin, global = true)]
+        margin: f64,
+    },
+}
+
+#[derive(Subcommand, Debug)]
+pub(crate) enum SelectionCommands {
+    /// Export all strokes. Alias: a
+    #[command(alias = "a")]
+    All,
+    /// Export a rectangular area of the document. Alias: r{n}
+    #[command(alias = "r")]
+    Rect {
+        /// x position of the starting point
+        x: f64,
+        /// y position of the starting point
+        y: f64,
+        /// width of the rectangle
+        width: f64,
+        /// height of the rectangle
+        height: f64,
+    },
+}
+
+#[derive(Args, Debug)]
+#[group(required = true, multiple = false)]
+pub(crate) struct FileArgs<T: ValueEnum + 'static + Send + Sync> {
+    /// the export output file. Only allows for one input file. Exclusive with --output-format
+    #[arg(short = 'o', long, global = true)]
+    output_file: Option<PathBuf>,
+    /// the export output format. Exclusive with --output-file
+    #[arg(short = 'f', long, global = true)]
+    output_format: Option<T>,
+}
+
+pub(crate) async fn run_export(
+    export_commands: ExportCommands,
+    engine: &mut RnoteEngine,
+    rnote_files: Vec<PathBuf>,
+    no_background: bool,
+    no_pattern: bool,
+    on_conflict: OnConflict,
+    open: bool,
+) -> anyhow::Result<()> {
+    if rnote_files.is_empty() {
+        return Err(anyhow::anyhow!("No rnote files to export!"));
+    }
+    let mut on_conflict_overwrite = None;
+    let output_file = match &export_commands {
+        ExportCommands::Doc { file_args, .. } => file_args.output_file.as_ref(),
+        ExportCommands::Selection { file_args, .. } => file_args.output_file.as_ref(),
+        ExportCommands::DocPages {
+            output_file_stem, ..
+        } => {
+            if rnote_files.len() > 1 && output_file_stem.is_some() {
+                return Err(anyhow::anyhow!(
+                    "You cannot use --file-stem when exporting multiple rnote files"
+                ));
+            }
+            None
+        }
+    };
+    apply_export_prefs(
+        engine,
+        &export_commands,
+        output_file,
+        no_background,
+        no_pattern,
+    )?;
+    match output_file {
+        Some(output_file) => {
+            match rnote_files.get(0) {
+                Some(rnote_file) => {
+                    validators::file_has_ext(rnote_file, "rnote")?;
+                    let output_file = get_output_file_path(
+                        output_file,
+                        on_conflict,
+                        &mut on_conflict_overwrite,
+                        &export_commands,
+                    )?;
+                    if rnote_files.len() > 1 {
+                        return Err(anyhow::anyhow!("Was expecting only 1 file. Use --output-format when exporting multiple files."));
+                    }
+
+                    let rnote_file_disp = rnote_file.display().to_string();
+                    let output_file_disp = output_file.display().to_string();
+                    let pb = new_pb(format!(
+                        "Exporting \"{rnote_file_disp}\" to: \"{output_file_disp}\""
+                    ));
+
+                    // export
+                    if let Err(e) = export_to_file(
+                        engine,
+                        rnote_file,
+                        output_file,
+                        &export_commands,
+                        on_conflict,
+                        &mut on_conflict_overwrite,
+                        open,
+                    )
+                    .await
+                    {
+                        let msg = format!("Export \"{rnote_file_disp}\" to: \"{output_file_disp}\" failed, Err {e:?}");
+                        if pb.is_hidden() {
+                            println!("{msg}")
+                        }
+                        pb.abandon_with_message(msg);
+                        return Err(e);
+                    } else {
+                        let msg = format!(
+                            "Export \"{rnote_file_disp}\" to: \"{output_file_disp}\" succeeded"
+                        );
+                        if pb.is_hidden() {
+                            println!("{msg}")
+                        }
+                        pb.finish_with_message(msg);
+                    }
+                }
+                None => return Err(anyhow::anyhow!("Failed to get filename from rnote_files")),
+            }
+        }
+
+        None => {
+            let doc_pages = matches!(export_commands, ExportCommands::DocPages { .. });
+            let output_ext = get_output_ext(engine, &export_commands);
+            let output_files = rnote_files
+                .iter()
+                .map(|file| {
+                    let mut output = file.clone();
+                    output.set_extension(&output_ext);
+                    output
+                })
+                .collect::<Vec<PathBuf>>();
+
+            for (rnote_file, output_file) in rnote_files.iter().zip(output_files.iter()) {
+                validators::file_has_ext(rnote_file, "rnote")?;
+                let output_file = match get_output_file_path(
+                    output_file,
+                    on_conflict,
+                    &mut on_conflict_overwrite,
+                    &export_commands,
+                ) {
+                    Ok(file) => file,
+                    Err(e) => {
+                        println!("Failed to generate output file path: {e}");
+                        continue;
+                    }
+                };
+
+                let rnote_file_disp = rnote_file.display().to_string();
+                let output_file_disp = output_file.display().to_string();
+                let pb = new_pb(match doc_pages {
+                    true => format!("Exporting \"{rnote_file_disp}\""),
+                    false => format!("Exporting \"{rnote_file_disp}\" to: \"{output_file_disp}\""),
+                });
+
+                // export
+                if let Err(e) = export_to_file(
+                    engine,
+                    &rnote_file,
+                    output_file,
+                    &export_commands,
+                    on_conflict,
+                    &mut on_conflict_overwrite,
+                    open,
+                )
+                .await
+                {
+                    let msg = match doc_pages {
+                        true => format!("Export \"{rnote_file_disp}\" failed, Err {e:?}"),
+                        false => format!(
+                        "Export \"{rnote_file_disp}\" to: \"{output_file_disp}\" failed, Err {e:?}"
+                    ),
+                    };
+                    if pb.is_hidden() {
+                        println!("{msg}")
+                    }
+                    pb.abandon_with_message(msg);
+                    return Err(e);
+                } else {
+                    let msg = match doc_pages {
+                        false => format!(
+                            "Export \"{rnote_file_disp}\" to: \"{output_file_disp}\" succeeded"
+                        ),
+                        true => format!("Export \"{rnote_file_disp}\" succeeded"),
+                    };
+                    if pb.is_hidden() {
+                        println!("{msg}")
+                    }
+                    pb.finish_with_message(msg);
+                }
+            }
+        }
+    }
+
+    println!("Export Finished!");
+    Ok(())
+}
+
+fn apply_export_prefs(
+    engine: &mut RnoteEngine,
+    export_commands: &ExportCommands,
+    output_file: Option<&PathBuf>,
+    no_background: bool,
+    no_pattern: bool,
+) -> anyhow::Result<()> {
+    match &export_commands {
+        ExportCommands::Doc {
+            file_args,
+            page_order,
+        } => {
+            engine.export_prefs.doc_export_prefs = create_doc_export_prefs_from_args(
+                output_file,
+                file_args.output_format,
+                no_background,
+                no_pattern,
+                *page_order,
+            )?
+        }
+        ExportCommands::DocPages {
+            output_format,
+            page_order,
+            bitmap_scalefactor,
+            jpeg_quality,
+            ..
+        } => {
+            engine.export_prefs.doc_pages_export_prefs = DocPagesExportPrefs {
+                export_format: *output_format,
+                with_background: !no_background,
+                with_pattern: !no_pattern,
+                page_order: *page_order,
+                bitmap_scalefactor: *bitmap_scalefactor,
+                jpeg_quality: *jpeg_quality,
+            };
+        }
+        ExportCommands::Selection {
+            file_args,
+            bitmap_scalefactor,
+            jpeg_quality,
+            margin,
+            ..
+        } => {
+            engine.export_prefs.selection_export_prefs = create_selection_export_prefs_from_args(
+                output_file,
+                file_args.output_format,
+                no_background,
+                no_pattern,
+                *bitmap_scalefactor,
+                *jpeg_quality,
+                *margin,
+            )?
+        }
+    }
+    Ok(())
+}
+
+fn get_output_ext(engine: &mut RnoteEngine, export_commands: &ExportCommands) -> String {
+    match &export_commands {
+        ExportCommands::Doc { .. } => engine
+            .export_prefs
+            .doc_export_prefs
+            .export_format
+            .file_ext(),
+        ExportCommands::DocPages { .. } => engine
+            .export_prefs
+            .doc_pages_export_prefs
+            .export_format
+            .file_ext(),
+        ExportCommands::Selection { .. } => engine
+            .export_prefs
+            .selection_export_prefs
+            .export_format
+            .file_ext(),
+    }
+}
+
+pub(crate) fn create_doc_export_prefs_from_args(
+    output_file: Option<impl AsRef<Path>>,
+    output_format: Option<DocExportFormat>,
+    no_background: bool,
+    no_pattern: bool,
+    page_order: SplitOrder,
+) -> anyhow::Result<DocExportPrefs> {
+    let format = match (output_file, output_format) {
+        (Some(file), None) => match file.as_ref().extension().and_then(|ext| ext.to_str()) {
+            Some(extension) => get_doc_export_format(extension),
+            None => {
+                return Err(anyhow::anyhow!(
+                    "Output file needs to have an extension to determine the file type"
+                ))
+            }
+        },
+        (None, Some(out_format)) => Ok(out_format),
+        // unreachable because they are exclusive (conflicts_with)
+        (Some(_), Some(_)) => {
+            return Err(anyhow::anyhow!(
+                "--output-file and --output-format are mutually exclusive."
+            ))
+        }
+        // unreachable because they are required
+        (None, None) => {
+            return Err(anyhow::anyhow!(
+                "--output-file or --output-format is required."
+            ))
+        }
+    }?;
+
+    let prefs = DocExportPrefs {
+        export_format: format,
+        with_background: !no_background,
+        with_pattern: !no_pattern,
+        page_order,
+    };
+
+    Ok(prefs)
+}
+
+fn get_doc_export_format(format: &str) -> anyhow::Result<DocExportFormat> {
+    match format {
+        "svg" => Ok(DocExportFormat::Svg),
+        "xopp" => Ok(DocExportFormat::Xopp),
+        "pdf" => Ok(DocExportFormat::Pdf),
+        ext => Err(anyhow::anyhow!(
+            "Could not create doc export prefs, unsupported export file extension `{ext}`"
+        )),
+    }
+}
+
+pub(crate) fn create_selection_export_prefs_from_args(
+    output_file: Option<impl AsRef<Path>>,
+    output_format: Option<SelectionExportFormat>,
+    no_background: bool,
+    no_pattern: bool,
+    bitmap_scalefactor: f64,
+    jpeg_quality: u8,
+    margin: f64,
+) -> anyhow::Result<SelectionExportPrefs> {
+    let format = match (output_file, output_format) {
+        (Some(file), None) => match file.as_ref().extension().and_then(|ext| ext.to_str()) {
+            Some(extension) => get_selection_export_format(extension),
+            None => {
+                return Err(anyhow::anyhow!(
+                    "Output file needs to have an extension to determine the file type"
+                ))
+            }
+        },
+        (None, Some(out_format)) => Ok(out_format),
+        // unreachable because they are exclusive (conflicts_with)
+        (Some(_), Some(_)) => {
+            return Err(anyhow::anyhow!(
+                "--output-file and --output-format are mutually exclusive."
+            ))
+        }
+        // unreachable because they are required
+        (None, None) => {
+            return Err(anyhow::anyhow!(
+                "--output-file or --output-format is required."
+            ))
+        }
+    }?;
+
+    let prefs = SelectionExportPrefs {
+        export_format: format,
+        with_background: !no_background,
+        with_pattern: !no_pattern,
+        bitmap_scalefactor,
+        jpeg_quality,
+        margin,
+    };
+
+    Ok(prefs)
+}
+
+fn get_selection_export_format(format: &str) -> anyhow::Result<SelectionExportFormat> {
+    match format {
+        "svg" => Ok(SelectionExportFormat::Svg),
+        "png" => Ok(SelectionExportFormat::Png),
+        "jpg" | "jpeg" => Ok(SelectionExportFormat::Jpeg),
+        ext => Err(anyhow::anyhow!(
+            "Could not create selection export prefs, unsupported export file extension `{ext}`"
+        )),
+    }
+}
+
+pub(crate) fn get_output_file_path(
+    initial_output_file: &Path,
+    on_conflict: OnConflict,
+    on_conflict_overwrite: &mut Option<OnConflict>,
+    export_commands: &ExportCommands,
+) -> anyhow::Result<PathBuf> {
+    match export_commands {
+        // output file will be ignored when parsing output file
+        ExportCommands::DocPages { .. } => Ok(initial_output_file.to_path_buf()),
+        _ => Ok(
+            check_file_conflict(initial_output_file, on_conflict, on_conflict_overwrite)?
+                .unwrap_or(initial_output_file.to_path_buf()),
+        ),
+    }
+}
+
+pub(crate) fn check_file_conflict(
+    output_file: &Path,
+    mut on_conflict: OnConflict,
+    on_conflict_overwrite: &mut Option<OnConflict>,
+) -> anyhow::Result<Option<PathBuf>> {
+    if !output_file.exists() {
+        return Ok(None);
+    }
+    if atty::isnt(atty::Stream::Stdout) {
+        return Err(anyhow::anyhow!(
+            "File conflict detected and terminal is not interactive. Please supply --on-conflict"
+        ));
+    }
+    match on_conflict_overwrite {
+        Some(o) => on_conflict = *o,
+        None => {
+            let options = &[
+                OnConflict::Ask,
+                OnConflict::Overwrite,
+                OnConflict::AlwaysOverwrite,
+                OnConflict::Skip,
+                OnConflict::AlwaysSkip,
+                OnConflict::Suffix,
+                OnConflict::AlwaysSuffix,
+            ];
+            while matches!(on_conflict, OnConflict::Ask) {
+                match dialoguer::Select::new()
+                    .with_prompt(format!("File \"{}\" already exists:", output_file.display()))
+                    .items(options)
+                    .default(1)
+                    .interact()
+                {
+                    Ok(0) => {
+                        open_file(true, output_file)?
+                    }
+                    Ok(c) => on_conflict = options[c],
+                    Err(e) => {
+                        return Err(anyhow::anyhow!(
+                            "Failed to show select prompt, retry or select an behavior with --on-conflict, {e:?}"
+                        ))
+                    }
+                };
+            }
+        }
+    };
+    match on_conflict {
+        OnConflict::Ask => return Err(anyhow::anyhow!("Failed to save user choice!")),
+        OnConflict::AlwaysOverwrite => {
+            on_conflict = OnConflict::Overwrite;
+            *on_conflict_overwrite = Some(on_conflict);
+        }
+        OnConflict::AlwaysSkip => {
+            on_conflict = OnConflict::Skip;
+            *on_conflict_overwrite = Some(on_conflict);
+        }
+        OnConflict::AlwaysSuffix => {
+            on_conflict = OnConflict::Suffix;
+            *on_conflict_overwrite = Some(on_conflict);
+        }
+        OnConflict::Overwrite | OnConflict::Skip | OnConflict::Suffix => (),
+    }
+    match on_conflict {
+        OnConflict::Ask => Err(anyhow::anyhow!("Failed to save user choice!")),
+        OnConflict::Overwrite => Ok(None),
+        OnConflict::Skip => Err(anyhow::anyhow!("Skipped {}", output_file.display())),
+        OnConflict::Suffix => {
+            let mut i = 0;
+            let mut new_path = output_file.to_path_buf();
+            let Some(file_stem) = new_path.file_stem().map(|s| s.to_string_lossy().to_string()) else {
+                return Err(anyhow::anyhow!("Failed to get file stem"));
+            };
+            let ext = new_path
+                .extension()
+                .map(|n| n.to_string_lossy().to_string())
+                .unwrap_or(String::new());
+            while new_path.exists() {
+                i += 1;
+                new_path.set_file_name(format!("{file_stem}_{i}.{ext}"))
+            }
+            Ok(Some(new_path))
+        }
+        OnConflict::AlwaysOverwrite | OnConflict::AlwaysSkip | OnConflict::AlwaysSuffix => {
+            Err(anyhow::anyhow!("Failed to set on_conflict_overwrite"))
+        }
+    }
+}
+
+pub(crate) async fn export_to_file(
+    engine: &mut RnoteEngine,
+    rnote_file: impl AsRef<Path>,
+    output_file: impl AsRef<Path>,
+    export_commands: &ExportCommands,
+    on_conflict: OnConflict,
+    on_conflict_overwrite: &mut Option<OnConflict>,
+    open: bool,
+) -> anyhow::Result<()> {
+    let rnote_file = rnote_file.as_ref();
+    let rnote_bytes = {
+        let mut out = vec![];
+        File::open(rnote_file).await?.read_to_end(&mut out).await?;
+        out
+    };
+    let engine_snapshot = EngineSnapshot::load_from_rnote_bytes(rnote_bytes).await?;
+    let _ = engine.load_snapshot(engine_snapshot);
+
+    match export_commands {
+        ExportCommands::Selection {
+            selection,
+            selection_collision,
+            ..
+        } => {
+            select_strokes_in_selection(engine, selection, selection_collision)?;
+            let export_bytes = engine
+                .export_selection(None)
+                .await??
+                .context("No strokes selected")?;
+            create_overwrite_file_w_bytes(&output_file, &export_bytes).await?;
+            open_file(open, output_file)?;
+        }
+        ExportCommands::Doc { .. } => {
+            let Some(export_file_name) = output_file.as_ref().file_name().map(|s| s.to_string_lossy().to_string()) else {
+                return Err(anyhow::anyhow!("Failed to get filename from output_file"));
+            };
+            let export_bytes = engine.export_doc(export_file_name, None).await??;
+            create_overwrite_file_w_bytes(&output_file, &export_bytes).await?;
+            open_file(open, output_file)?;
+        }
+        ExportCommands::DocPages {
+            output_dir,
+            output_file_stem,
+            output_format,
+            ..
+        } => {
+            validators::path_is_dir(output_dir)?;
+            // The output file cannot be set with this subcommand
+            drop(output_file);
+
+            let export_bytes = engine.export_doc_pages(None).await??;
+            let out_ext = output_format.file_ext();
+            let output_file_stem = match output_file_stem {
+                Some(o) => o.clone(),
+                None => match rnote_file.file_stem() {
+                    Some(stem) => stem.to_string_lossy().to_string(),
+                    None => {
+                        return Err(anyhow::anyhow!(
+                            "Failed to generate output_file_stem from rnote_file"
+                        ))
+                    }
+                },
+            };
+            let pages_amount = export_bytes.len();
+            for (i, bytes) in export_bytes.into_iter().enumerate() {
+                create_overwrite_file_w_bytes(
+                    &doc_page_output_file(
+                        i,
+                        pages_amount,
+                        output_dir,
+                        &out_ext,
+                        &output_file_stem,
+                        on_conflict,
+                        on_conflict_overwrite,
+                    )?,
+                    &bytes,
+                )
+                .await
+                .context(format!(
+                    "Failed to export page {i} of document {}",
+                    rnote_file.display()
+                ))?
+            }
+            open_file(open, output_dir)?;
+        }
+    };
+    Ok(())
+}
+
+fn select_strokes_in_selection(
+    engine: &mut RnoteEngine,
+    selection: &SelectionCommands,
+    selection_collision: &SelectionCollision,
+) -> anyhow::Result<()> {
+    let (strokes, err_msg) = match selection {
+        SelectionCommands::Rect {
+            x,
+            y,
+            width,
+            height,
+        } => {
+            let v1 = Vector2::new(*x, *y);
+            let v2 = v1 + Vector2::new(*width, *height);
+            let points = vec![v1.into(), v2.into()];
+            let aabb = Aabb::from_points(&points);
+            (
+                match selection_collision {
+                    SelectionCollision::Contains => {
+                        engine.store.stroke_keys_as_rendered_in_bounds(aabb)
+                    }
+                    SelectionCollision::Intersects => engine
+                        .store
+                        .stroke_keys_as_rendered_intersecting_bounds(aabb),
+                },
+                "No strokes in given rectangle",
+            )
+        }
+        SelectionCommands::All => (engine.store.stroke_keys_as_rendered(), "Document is empty"),
+    };
+    if strokes.is_empty() {
+        return Err(anyhow::anyhow!("{err_msg}"));
+    }
+    engine.store.set_selected_keys(&strokes, true);
+    Ok(())
+}
+
+fn doc_page_output_file(
+    mut i: usize,
+    pages_amount: usize,
+    output_dir: &Path,
+    out_ext: &str,
+    output_file_stem: &str,
+    on_conflict: OnConflict,
+    on_conflict_overwrite: &mut Option<OnConflict>,
+) -> anyhow::Result<PathBuf> {
+    i += 1;
+    // match digits of page amount to prepend leading zeros on lower numbers
+    let number = match pages_amount.to_string().len() {
+        2 => format!("{i:02}"),
+        3 => format!("{i:03}"),
+        4 => format!("{i:04}"),
+        5 => format!("{i:05}"),
+        _ => i.to_string(),
+    };
+    let mut out = output_dir.join(format!("{output_file_stem} - page {number}.{out_ext}"));
+    if let Some(new_out) = check_file_conflict(out.as_ref(), on_conflict, on_conflict_overwrite)? {
+        out = new_out;
+    }
+    Ok(out)
+}
+
+async fn create_overwrite_file_w_bytes(
+    output_file: impl AsRef<Path>,
+    bytes: &[u8],
+) -> anyhow::Result<()> {
+    let mut fh = File::create(output_file).await?;
+    fh.write_all(bytes).await?;
+    fh.sync_all().await?;
+    Ok(())
+}
+
+fn open_file(open: bool, path: impl AsRef<Path>) -> anyhow::Result<()> {
+    if open {
+        open::that_detached(path.as_ref()).with_context(|| {
+            format!(
+                "Failed to open output file/folder {}",
+                path.as_ref().display()
+            )
+        })?;
+    }
+    Ok(())
+}
diff --git a/crates/rnote-cli/src/main.rs b/crates/rnote-cli/src/main.rs
index d455634168..6db2fed8ed 100644
--- a/crates/rnote-cli/src/main.rs
+++ b/crates/rnote-cli/src/main.rs
@@ -3,6 +3,8 @@
 //! The cli interface is not (yet) stable and could change at any time.
 
 pub(crate) mod cli;
+pub(crate) mod export;
+pub(crate) mod validators;
 
 fn main() -> anyhow::Result<()> {
     smol::block_on(async { cli::run().await })
diff --git a/crates/rnote-cli/src/meson.build b/crates/rnote-cli/src/meson.build
index e038aa3b31..795ddf7b4c 100644
--- a/crates/rnote-cli/src/meson.build
+++ b/crates/rnote-cli/src/meson.build
@@ -1,5 +1,7 @@
 # Specify sources
 rnote_cli_sources = files(
     'cli.rs',
+    'export.rs',
     'main.rs',
-)
\ No newline at end of file
+    'validators.rs',
+)
diff --git a/crates/rnote-cli/src/validators.rs b/crates/rnote-cli/src/validators.rs
new file mode 100644
index 0000000000..9283c1c0d9
--- /dev/null
+++ b/crates/rnote-cli/src/validators.rs
@@ -0,0 +1,32 @@
+use std::path::Path;
+
+pub(crate) fn path_is_dir(path: &Path) -> anyhow::Result<()> {
+    if !path.is_dir() {
+        return Err(anyhow::anyhow!(
+            "expected directory, found file {}",
+            path.display()
+        ));
+    }
+    Ok(())
+}
+
+pub(crate) fn path_is_file(path: &Path) -> anyhow::Result<()> {
+    if !path.is_file() {
+        return Err(anyhow::anyhow!(
+            "expected file, found directory {}",
+            path.display()
+        ));
+    }
+    Ok(())
+}
+
+pub(crate) fn file_has_ext(path: &Path, expected_ext: &str) -> anyhow::Result<()> {
+    path_is_file(path)?;
+    match path.extension() {
+        Some(ext) if ext == expected_ext => Ok(()),
+        _ => Err(anyhow::anyhow!(
+            "expected .{expected_ext} file, found {}",
+            path.display()
+        )),
+    }
+}
diff --git a/crates/rnote-compose/Cargo.toml b/crates/rnote-compose/Cargo.toml
index 04603e3dac..77986310d9 100644
--- a/crates/rnote-compose/Cargo.toml
+++ b/crates/rnote-compose/Cargo.toml
@@ -30,3 +30,7 @@ piet = { workspace = true }
 piet-cairo = { workspace = true }
 roughr = { workspace = true }
 rough_piet = { workspace = true }
+clap = { workspace = true, optional = true }
+
+[features]
+clap-derive = [ "dep:clap" ]
diff --git a/crates/rnote-compose/src/splitorder.rs b/crates/rnote-compose/src/splitorder.rs
index 556bd1fd3f..91205317ab 100644
--- a/crates/rnote-compose/src/splitorder.rs
+++ b/crates/rnote-compose/src/splitorder.rs
@@ -12,20 +12,33 @@ use serde::{Deserialize, Serialize};
     Deserialize,
     num_derive::FromPrimitive,
     num_derive::ToPrimitive,
+    Default,
 )]
+#[cfg_attr(feature = "clap-derive", derive(clap::ValueEnum))]
 #[serde(rename = "split_order")]
 pub enum SplitOrder {
+    #[default]
     /// Split in row-major order.
     #[serde(rename = "row_major")]
+    #[cfg_attr(feature = "clap-derive", value(name = "horizontal-first"))]
     RowMajor,
     /// Split in column-major order.
     #[serde(rename = "column_major")]
+    #[cfg_attr(feature = "clap-derive", value(name = "vertical-first"))]
     ColumnMajor,
 }
 
-impl Default for SplitOrder {
-    fn default() -> Self {
-        Self::RowMajor
+#[cfg(feature = "clap-derive")]
+impl std::fmt::Display for SplitOrder {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}",
+            match self {
+                SplitOrder::RowMajor => "horizontal",
+                SplitOrder::ColumnMajor => "vertical",
+            }
+        )
     }
 }
 
diff --git a/crates/rnote-engine/Cargo.toml b/crates/rnote-engine/Cargo.toml
index cd3dc72c95..23ef3dbe8c 100644
--- a/crates/rnote-engine/Cargo.toml
+++ b/crates/rnote-engine/Cargo.toml
@@ -54,6 +54,10 @@ rsvg = { workspace = true }
 poppler-rs = { workspace = true }
 # the long-term plan is to remove the gtk4 dependency entirely after switching to another renderer.
 gtk4 = { workspace = true }
+clap = { workspace = true, optional = true }
 
 [dev-dependencies]
 approx = { workspace = true }
+
+[features]
+clap-derive = [ "dep:clap" ]
diff --git a/crates/rnote-engine/src/engine/export.rs b/crates/rnote-engine/src/engine/export.rs
index f4ac61565f..dadf52664a 100644
--- a/crates/rnote-engine/src/engine/export.rs
+++ b/crates/rnote-engine/src/engine/export.rs
@@ -24,6 +24,7 @@ use std::sync::Arc;
     num_derive::FromPrimitive,
     num_derive::ToPrimitive,
 )]
+#[cfg_attr(feature = "clap-derive", derive(clap::ValueEnum))]
 #[serde(rename = "doc_export_format")]
 pub enum DocExportFormat {
     #[serde(rename = "svg")]
@@ -111,6 +112,7 @@ impl DocExportPrefs {
     num_derive::FromPrimitive,
     num_derive::ToPrimitive,
 )]
+#[cfg_attr(feature = "clap-derive", derive(clap::ValueEnum))]
 #[serde(rename = "doc_pages_export_format")]
 pub enum DocPagesExportFormat {
     #[serde(rename = "svg")]
@@ -205,6 +207,7 @@ impl Default for DocPagesExportPrefs {
     num_derive::FromPrimitive,
     num_derive::ToPrimitive,
 )]
+#[cfg_attr(feature = "clap-derive", derive(clap::ValueEnum))]
 #[serde(rename = "selection_export_format")]
 pub enum SelectionExportFormat {
     #[serde(rename = "svg")]
diff --git a/crates/rnote-engine/src/lib.rs b/crates/rnote-engine/src/lib.rs
index 6fecabc164..e2e39508b5 100644
--- a/crates/rnote-engine/src/lib.rs
+++ b/crates/rnote-engine/src/lib.rs
@@ -18,6 +18,7 @@ pub mod ext;
 pub mod fileformats;
 pub mod pens;
 pub mod render;
+pub mod selectioncollision;
 pub mod store;
 pub mod strokes;
 pub mod tasks;
@@ -32,6 +33,7 @@ pub use drawable::Drawable;
 pub use drawable::DrawableOnDoc;
 pub use engine::RnoteEngine;
 pub use pens::PenHolder;
+pub use selectioncollision::SelectionCollision;
 pub use store::StrokeStore;
 pub use widgetflags::WidgetFlags;
 
diff --git a/crates/rnote-engine/src/meson.build b/crates/rnote-engine/src/meson.build
index 9c5931d18c..61d323a12d 100644
--- a/crates/rnote-engine/src/meson.build
+++ b/crates/rnote-engine/src/meson.build
@@ -57,6 +57,7 @@ rnote_engine_sources = files(
     'ext.rs',
     'lib.rs',
     'render.rs',
+    'selectioncollision.rs',
     'tasks.rs',
     'utils.rs',
     'widgetflags.rs',
diff --git a/crates/rnote-engine/src/selectioncollision.rs b/crates/rnote-engine/src/selectioncollision.rs
new file mode 100644
index 0000000000..7e4eee1ae1
--- /dev/null
+++ b/crates/rnote-engine/src/selectioncollision.rs
@@ -0,0 +1,22 @@
+#[derive(Clone, Debug, Default)]
+#[cfg_attr(feature = "clap-derive", derive(clap::ValueEnum))]
+pub enum SelectionCollision {
+    #[default]
+    /// All strokes completely inside the area
+    Contains,
+    /// All Strokes intersecting with the area
+    Intersects,
+}
+
+impl std::fmt::Display for SelectionCollision {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}",
+            match self {
+                SelectionCollision::Contains => "contains",
+                SelectionCollision::Intersects => "intersects",
+            }
+        )
+    }
+}


Test patch: