From 7f3ff8aafcaf3137bec9993329667e5e9fbf4c4e Mon Sep 17 00:00:00 2001 From: Konstantin Quillfeldt <87606259+kq98@users.noreply.github.com> Date: Sat, 9 Nov 2024 18:53:34 +0100 Subject: [PATCH] feat: Allow import of encrypted PDF files (#1279) --- crates/rnote-engine/src/engine/import.rs | 3 + .../rnote-engine/src/strokes/bitmapimage.rs | 4 +- .../rnote-engine/src/strokes/vectorimage.rs | 3 +- crates/rnote-ui/data/ui/dialogs/import.ui | 25 ++++ crates/rnote-ui/po/de.po | 24 +++- crates/rnote-ui/po/rnote.pot | 20 +++ crates/rnote-ui/src/canvas/imexport.rs | 3 +- crates/rnote-ui/src/dialogs/import.rs | 126 +++++++++++++++++- 8 files changed, 197 insertions(+), 11 deletions(-) diff --git a/crates/rnote-engine/src/engine/import.rs b/crates/rnote-engine/src/engine/import.rs index 7ba5359f31..a10d54b454 100644 --- a/crates/rnote-engine/src/engine/import.rs +++ b/crates/rnote-engine/src/engine/import.rs @@ -318,6 +318,7 @@ impl Engine { bytes: Vec, insert_pos: na::Vector2, page_range: Option>, + password: Option, ) -> oneshot::Receiver)>>> { let (oneshot_sender, oneshot_receiver) = oneshot::channel::)>>>(); @@ -339,6 +340,7 @@ impl Engine { insert_pos, page_range, &format, + password, )? .into_iter() .map(|s| (Stroke::BitmapImage(s), Some(StrokeLayer::Document))) @@ -352,6 +354,7 @@ impl Engine { insert_pos, page_range, &format, + password, )? .into_iter() .map(|s| (Stroke::VectorImage(s), Some(StrokeLayer::Document))) diff --git a/crates/rnote-engine/src/strokes/bitmapimage.rs b/crates/rnote-engine/src/strokes/bitmapimage.rs index 58d57ff71b..5274c0dabd 100644 --- a/crates/rnote-engine/src/strokes/bitmapimage.rs +++ b/crates/rnote-engine/src/strokes/bitmapimage.rs @@ -132,8 +132,10 @@ impl BitmapImage { insert_pos: na::Vector2, page_range: Option>, format: &Format, + password: Option, ) -> Result, anyhow::Error> { - let doc = poppler::Document::from_bytes(&glib::Bytes::from(to_be_read), None)?; + let doc = + poppler::Document::from_bytes(&glib::Bytes::from(to_be_read), password.as_deref())?; let page_range = page_range.unwrap_or(0..doc.n_pages() as u32); let page_width = if pdf_import_prefs.adjust_document { format.width() diff --git a/crates/rnote-engine/src/strokes/vectorimage.rs b/crates/rnote-engine/src/strokes/vectorimage.rs index fcec15f28f..4023f01afc 100644 --- a/crates/rnote-engine/src/strokes/vectorimage.rs +++ b/crates/rnote-engine/src/strokes/vectorimage.rs @@ -210,8 +210,9 @@ impl VectorImage { insert_pos: na::Vector2, page_range: Option>, format: &Format, + password: Option, ) -> Result, anyhow::Error> { - let doc = poppler::Document::from_bytes(&glib::Bytes::from(bytes), None)?; + let doc = poppler::Document::from_bytes(&glib::Bytes::from(bytes), password.as_deref())?; let page_range = page_range.unwrap_or(0..doc.n_pages() as u32); let page_width = if pdf_import_prefs.adjust_document { diff --git a/crates/rnote-ui/data/ui/dialogs/import.ui b/crates/rnote-ui/data/ui/dialogs/import.ui index 9a3521c5e9..37ef4918f5 100644 --- a/crates/rnote-ui/data/ui/dialogs/import.ui +++ b/crates/rnote-ui/data/ui/dialogs/import.ui @@ -269,4 +269,29 @@ 1 96 + + is password protected + unlock + cancel + Encrypted PDF + False + + _Cancel + _Unlock + + + + none + + + + True + Enter the PDF password + + + + + diff --git a/crates/rnote-ui/po/de.po b/crates/rnote-ui/po/de.po index 243d28a83b..c6cce00afc 100644 --- a/crates/rnote-ui/po/de.po +++ b/crates/rnote-ui/po/de.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: rnote\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2024-07-26 10:39+0200\n" -"PO-Revision-Date: 2024-08-24 17:28+0000\n" +"PO-Revision-Date: 2024-11-07 22:58+0100\n" "Last-Translator: Felix Zwettler \n" "Language-Team: German \n" "Language: de\n" @@ -16,7 +16,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.7.1-dev\n" +"X-Generator: Poedit 3.4.4\n" #: crates/rnote-ui/data/app.desktop.in.in:5 #: crates/rnote-ui/data/app.metainfo.xml.in.in:9 @@ -2536,6 +2536,26 @@ msgctxt "part of string representation of a color" msgid "white" msgstr "weiss" +#: crates/rnote-ui/data/ui/dialogs/import.ui:273 +msgid "is password protected" +msgstr "ist Passwort geschützt" + +#: crates/rnote-ui/data/ui/dialogs/import.ui:276 +msgid "Encrypted PDF" +msgstr "Verschlüsselte PDF" + +#: crates/rnote-ui/data/ui/dialogs/import.ui:279 +msgid "_Cancel" +msgstr "_Abbrechen" + +#: crates/rnote-ui/data/ui/dialogs/import.ui:280 +msgid "_Unlock" +msgstr "_Entsperren" + +#: crates/rnote-ui/data/ui/dialogs/import.ui:287 +msgid "Enter the PDF password" +msgstr "Gebe das PDF Passwort ein" + #~ msgid "Opened file was moved or deleted on disk" #~ msgstr "Geöffnete Datei wurde auf dem Datenträger verschoben oder gelöscht" diff --git a/crates/rnote-ui/po/rnote.pot b/crates/rnote-ui/po/rnote.pot index 8aba63704f..3520c26b28 100644 --- a/crates/rnote-ui/po/rnote.pot +++ b/crates/rnote-ui/po/rnote.pot @@ -2476,3 +2476,23 @@ msgstr "" msgctxt "part of string representation of a color" msgid "white" msgstr "" + +#: crates/rnote-ui/data/ui/dialogs/import.ui:273 +msgid "is password protected" +msgstr "" + +#: crates/rnote-ui/data/ui/dialogs/import.ui:276 +msgid "Encrypted PDF" +msgstr "" + +#: crates/rnote-ui/data/ui/dialogs/import.ui:279 +msgid "_Cancel" +msgstr "" + +#: crates/rnote-ui/data/ui/dialogs/import.ui:280 +msgid "_Unlock" +msgstr "" + +#: crates/rnote-ui/data/ui/dialogs/import.ui:287 +msgid "Enter the PDF password" +msgstr "" \ No newline at end of file diff --git a/crates/rnote-ui/src/canvas/imexport.rs b/crates/rnote-ui/src/canvas/imexport.rs index 8a8b0da871..d6c3e699f7 100644 --- a/crates/rnote-ui/src/canvas/imexport.rs +++ b/crates/rnote-ui/src/canvas/imexport.rs @@ -129,6 +129,7 @@ impl RnCanvas { bytes: Vec, target_pos: Option>, page_range: Option>, + password: Option, ) -> anyhow::Result<()> { let pos = self.determine_stroke_import_pos(target_pos); let adjust_document = self @@ -139,7 +140,7 @@ impl RnCanvas { let strokes_receiver = self .engine_mut() - .generate_pdf_pages_from_bytes(bytes, pos, page_range); + .generate_pdf_pages_from_bytes(bytes, pos, page_range, password); let strokes = strokes_receiver.await??; let widget_flags = self .engine_mut() diff --git a/crates/rnote-ui/src/dialogs/import.rs b/crates/rnote-ui/src/dialogs/import.rs index 6f003ff6b2..339c10fc95 100644 --- a/crates/rnote-ui/src/dialogs/import.rs +++ b/crates/rnote-ui/src/dialogs/import.rs @@ -7,8 +7,8 @@ use adw::prelude::*; use futures::StreamExt; use gettextrs::gettext; use gtk4::{ - gio, glib, glib::clone, Builder, Button, CallbackAction, FileDialog, FileFilter, Label, - Shortcut, ShortcutController, ShortcutTrigger, ToggleButton, + gio, glib, glib::clone, graphene, gsk, Builder, Button, CallbackAction, FileDialog, FileFilter, + Label, Shortcut, ShortcutController, ShortcutTrigger, ToggleButton, }; use num_traits::ToPrimitive; use rnote_engine::engine::import::{PdfImportPageSpacing, PdfImportPagesType}; @@ -111,6 +111,115 @@ pub(crate) async fn filedialog_import_file(appwindow: &RnAppWindow) { } } +/// Check for a pdf encryption and request a password if needed from the user +/// +/// Returns a password Option and a boolean weather the user canceled the file import or not +pub(crate) async fn pdf_encryption_check_and_dialog( + appwindow: &RnAppWindow, + input_file: &gio::File, +) -> (Option, bool) { + let builder = Builder::from_resource( + (String::from(config::APP_IDPATH) + "ui/dialogs/import.ui").as_str(), + ); + + let dialog_import_pdf_password: adw::AlertDialog = + builder.object("dialog_import_pdf_password").unwrap(); + let pdf_password_entry: adw::PasswordEntryRow = builder.object("pdf_password_entry").unwrap(); + let pdf_password_entry_box: gtk4::ListBox = builder.object("pdf_password_entry_box").unwrap(); + + let target = adw::CallbackAnimationTarget::new(clone!( + #[weak] + pdf_password_entry_box, + move |value| { + let x = adw::lerp(0., 40.0, value); + let p = graphene::Point::new(x as f32, 0.); + let transform = gsk::Transform::new().translate(&p); + pdf_password_entry_box.allocate( + pdf_password_entry_box.width(), + pdf_password_entry_box.height(), + -1, + Some(transform), + ); + } + )); + + let params = adw::SpringParams::new(0.2, 0.5, 500.0); + + let animation = adw::SpringAnimation::builder() + .widget(&pdf_password_entry_box) + .value_from(0.0) + .value_to(0.0) + .spring_params(¶ms) + .target(&target) + .initial_velocity(10.0) + .epsilon(0.001) // If amplitude of oscillation < epsilon, animation stops + .clamp(false) + .build(); + + let (tx, mut rx) = futures::channel::mpsc::unbounded::<(Option, bool)>(); + let tx_cancel = tx.clone(); + let tx_unlock = tx.clone(); + + dialog_import_pdf_password.connect_response( + Some("unlock"), + clone!( + #[weak] + pdf_password_entry, + move |_, _| { + tx_unlock + .unbounded_send((Some(pdf_password_entry.text().to_string()), false)) + .unwrap(); + } + ), + ); + + dialog_import_pdf_password.connect_response(Some("cancel"), move |_, _| { + tx_cancel.unbounded_send((None, true)).unwrap(); + }); + + let file_name = input_file.basename().map_or_else( + || gettext("- no file name -"), + |s| s.to_string_lossy().to_string(), + ); + let dialog_body = dialog_import_pdf_password.body(); + let dialog_body = file_name.clone() + " " + &dialog_body; + dialog_import_pdf_password.set_body(&dialog_body); + + let mut password: Option = None; + + loop { + match poppler::Document::from_gfile( + input_file, + password.as_deref(), + None::<&gio::Cancellable>, + ) { + Ok(_) => return (password, false), + Err(e) => { + if e.matches(poppler::Error::Encrypted) { + dialog_import_pdf_password.present(appwindow.root().as_ref()); + pdf_password_entry.grab_focus(); + + match rx.next().await { + Some((new_password, cancel)) => { + password = new_password; + if cancel { + return (None, true); + } + } + None => { + return (None, true); + } + } + animation.play(); + pdf_password_entry.set_text(""); + } else { + return (None, true); + } + } + }; + } +} + /// Imports the file as Pdf with an import dialog. /// /// Returns true when the file was imported, else false. @@ -120,6 +229,11 @@ pub(crate) async fn dialog_import_pdf_w_prefs( input_file: gio::File, target_pos: Option>, ) -> anyhow::Result { + let (password, cancel) = pdf_encryption_check_and_dialog(appwindow, &input_file).await; + if cancel { + return Ok(false); + } + let builder = Builder::from_resource( (String::from(config::APP_IDPATH) + "ui/dialogs/import.ui").as_str(), ); @@ -274,7 +388,7 @@ pub(crate) async fn dialog_import_pdf_w_prefs( )); if let Ok(poppler_doc) = - poppler::Document::from_gfile(&input_file, None, None::<&gio::Cancellable>) + poppler::Document::from_gfile(&input_file, password.as_deref(), None::<&gio::Cancellable>) { let file_name = input_file.basename().map_or_else( || gettext("- no file name -"), @@ -346,12 +460,12 @@ pub(crate) async fn dialog_import_pdf_w_prefs( } )); - import_pdf_button_confirm.connect_clicked(clone!(#[weak] pdf_page_start_row, #[weak] pdf_page_end_row, #[weak] input_file, #[weak] dialog, #[weak] canvas , move |_| { + import_pdf_button_confirm.connect_clicked(clone!(#[weak] pdf_page_start_row, #[weak] pdf_page_end_row, #[weak] input_file, #[weak] dialog, #[weak] canvas, #[strong] password, move |_| { dialog.close(); let inner_tx_confirm = tx_confirm.clone(); - glib::spawn_future_local(clone!(#[weak] pdf_page_start_row, #[weak] pdf_page_end_row, #[weak] input_file, #[weak] canvas , async move { + glib::spawn_future_local(clone!(#[weak] pdf_page_start_row, #[weak] pdf_page_end_row, #[weak] input_file, #[weak] canvas, #[strong] password , async move { let page_range = (pdf_page_start_row.value() as u32 - 1)..pdf_page_end_row.value() as u32; @@ -364,7 +478,7 @@ pub(crate) async fn dialog_import_pdf_w_prefs( return; } }; - if let Err(e) = canvas.load_in_pdf_bytes(bytes.to_vec(), target_pos, Some(page_range)).await { + if let Err(e) = canvas.load_in_pdf_bytes(bytes.to_vec(), target_pos, Some(page_range), password).await { if let Err(e) = inner_tx_confirm.unbounded_send(Err(e)) { error!("Failed to load PDF, but failed to send signal through channel. Err: {e:?}"); }