diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 966ab2e5b..50dbce312 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "20abff97752d6840dbccd0c0ab084a9a720a7098b9cb0dc85cb4dd5e3aed035a" +sha256 = "07a12136c12f2ffab3f958991399de877c6c306b70235b9d9b6ea35af70a3038" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c8e6cf64f6445a1e728160526ad03f6c334d4c17e28a363fc3117e678f05a1ac" +sha256 = "4774f2e2ba768dc7dd102a250e107afd2b778bc1f0ff0769ea4e4dcbe0095068" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c2988904eab14668a0c3b3ab60f3ec633cefd9aefa51537025289d5a56be90a3" +sha256 = "050d4a70bd2c64bb1104098b17a5d58cc80fe1e3404194193454cdd7d7afa0a3" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "4c458a6cf8bf835ddacf2aae9fcc3268a17889572dd84533db3387df3ed8a9ae" +sha256 = "2f6259d83cc64b50267ea9f4d29524d8b0c24706d0014991d0eb11cf3366de75" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f1428a3e57fbe8c431a2893032c45333d8e603a84990020e7246c2ae89cdda63" +sha256 = "921eabe6a997a428cb55f588caf556fa2f7105a1b55f095f7ddbbb4c104a6e4c" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cf51169173fcbb05d8b543c99c757105235194745e3672da2b94c0344040d27f" +sha256 = "5916589c59d74474823c2b5007cf3ab38c286b756866ef39548d30a66fb89382" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a122ae46d112bd16238dc295e9514c59017874d76651195cd1f3d2849832084f" +sha256 = "6ec9c44295c74966f3786f3f5e994ca1940205e7d440266c48a4dc0022fa1f59" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "417725b7518473ae301f747fd8c5d25b17bd2ffb506e9a3b0b885d8332dbb7c9" +sha256 = "7d975aa37094e268293bf1e76338e0e14d435648557c812f7866f29ab4bd501c" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "13e8633ef2b0482dde1dcad37207c24507df9bf4e2a15c065e0d58e25b2e9e23" +sha256 = "20933c2d09f1e0cd764014521c8dffbf5b6899fb5244f3256357d501d332322f" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "aadfcc7a71ace8bd0de86d86846d88395ccbf6971e4ebf4e3e99b1d3d46e3913" +sha256 = "823d127d847950c545befd1ea8e94f99a34473bd9be7aebee93b72dca6b79b2f" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "527924f5558599bd7aeb1ad69a94fe58eeba6716e03e8ec1c6c061f3c53f2cdd" +sha256 = "c3b4fa31e77735baf25e06e16c9ae1e999cba7a1a7c3b765d6c7bdc84526a48f" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "91de7a1a8515a1f698567f6e0c9393a0eec111551f0de4ec03a8766b120e3222" +sha256 = "f228a767496a912e9d93d5bcd4323c8ffe406f9be93663efd6adf51151f43b8e" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1989ca69412d7092e643b170a41f9ee01920513ef4c71a4b2abf5d9a24a24e6b" +sha256 = "d5e5c58a63e1cde6224ccb1438064138f86de69d559c99f1a101f8d5e1daa7ed" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c149965b2886ad5cb1992aad3d50eda3a15c96aa36070a114a7240f62ce54684" +sha256 = "2960b61dcb97358a6c304b4b41d9ef8021d1e9cb86d313959d442fd226aed3bd" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 81f79d14e..92ea4e95d 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -271,7 +271,7 @@ impl Automation { let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, html or rtf)")))?; - let bytes = self.doc(a)?.export_office(format); + let bytes = self.doc_to_copy(a)?.export_office(format); write_atomic(&path, &bytes)?; Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() })) } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 6764f7739..1e2c872ff 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -444,7 +444,7 @@ impl Automation { } "text_paragraphs" => { let page = self.page(&a)?; - let doc = self.doc(&a)?; + let doc = self.doc_to_copy(&a)?; let info = &doc.info.pages[page]; let r = |x: f32| (x as f64 * 100.0).round() / 100.0; let blocks: Vec = doc @@ -699,6 +699,13 @@ impl Automation { self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)"))) } + /// The document, if its security allows copying text and images out of it (Table 22, bit 5). + fn doc_to_copy(&self, a: &Args) -> Result<&Document> { + let doc = self.doc(a)?; + doc.check_copying().map_err(failed)?; + Ok(doc) + } + fn doc_open(&mut self, a: &Args) -> Result { let path = self.resolve(a.str("path")?, false)?; let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; @@ -1027,7 +1034,7 @@ impl Automation { } fn export(&mut self, tool: &str, a: &Args) -> Result { - let doc = self.doc(a)?; + let doc = self.doc_to_copy(a)?; let pages = match a.opt_ints("pages")? { Some(_) => self.pages(a, "pages")?, None => (0..doc.info.pages.len()).collect(), @@ -1561,7 +1568,7 @@ impl Automation { } fn text_extract(&mut self, a: &Args) -> Result { - let doc = self.doc(a)?; + let doc = self.doc_to_copy(a)?; let id = doc.id; let pages = match a.opt_ints("pages")? { Some(_) => self.pages(a, "pages")?, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index bcdcce4a0..a1e3133f9 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1184,8 +1184,25 @@ fn protecting_through_tools() { assert!(matches!(b.call("page_delete", &json!({ "doc": re, "pages": [1] })), Err(ToolError::Failed(_)))); assert!(matches!(b.call("doc_unprotect", &json!({ "doc": re })), Err(ToolError::Failed(_)))); ok(&mut b, "comment_add", json!({ "doc": re, "page": 1, "type": "note", "at": [10, 10], "contents": "allowed" })); + // Copying wasn't allowed either (#568): no text or images come out, though search works. + for (tool, args) in [ + ("text_extract", json!({ "doc": re })), + ("text_paragraphs", json!({ "doc": re, "page": 1 })), + ("doc_export_text", json!({ "doc": re, "path": "out.txt" })), + ("doc_export_images", json!({ "doc": re, "folder": "pages" })), + ("doc_export_all_images", json!({ "doc": re, "folder": "images" })), + ("doc_export_office", json!({ "doc": re, "path": "out.docx" })), + ] { + match b.call(tool, &args) { + Err(ToolError::Failed(m)) => assert!(m.contains("don't allow copying"), "{tool}: {m}"), + other => panic!("{tool} is refused: {other:?}"), + } + } + assert!(!dir.join("out.txt").exists() && !dir.join("pages").exists() && !dir.join("out.docx").exists()); + assert_eq!(ok(&mut b, "text_find", json!({ "doc": re, "query": "Page" }))["count"], 3); // With the permissions password everything is possible, including removing security. let owner = ok(&mut b, "doc_open", json!({ "path": "locked.pdf", "password": "boss" }))["doc"].as_u64().unwrap(); + assert!(ok(&mut b, "text_extract", json!({ "doc": owner }))["pages"][0]["text"].as_str().is_some_and(|t| t.contains("Page"))); assert_eq!(ok(&mut b, "doc_unprotect", json!({ "doc": owner }))["security"]["protected"], false); ok(&mut b, "doc_save", json!({ "doc": owner, "path": "open.pdf" })); let mut c = auto(&dir); diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 2f313bda8..0771f27fb 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -24,6 +24,8 @@ pub enum Needs { Modification, /// The open document's security allows adding and changing comments. Annotate, + /// The open document's security allows copying text and images out of it. + Copying, /// The document has form fields and its security allows filling them in. FillForms, /// The document has comments and allows changes. @@ -205,7 +207,7 @@ pub const COMMANDS: &[CommandSpec] = &[ // drag with the Select tool does the same; this is the way in where the desktop takes // Alt-drag for moving windows (several Linux window managers). c("edit.column_select", "Column select", EDIT, None, Document, "text-select"), - c("edit.snapshot", "Take a snapshot", EDIT, None, Document, "camera"), + c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"), c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"), c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"), c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"), @@ -298,13 +300,13 @@ pub const COMMANDS: &[CommandSpec] = &[ c("sign.fill.signature.remove", "Fill & Sign: remove saved signature", None, None, Nothing, "x"), c("sign.fill.initials.remove", "Fill & Sign: remove saved initials", None, None, Nothing, "x"), c("sign.fill.initials.change", "Fill & Sign: change initials", None, None, Nothing, "signature"), - c("export.image", "Export to image…", FILE, None, Document, "image"), + c("export.image", "Export to image…", FILE, None, Copying, "image"), c("optimize.reduce", "Reduce file size…", FILE, None, Document, "file-down"), c("optimize.advanced", "Optimize PDF…", FILE, None, Document, "settings-2"), - c("export.text", "Export to text…", FILE, None, Document, "type"), - c("export.docx", "Export to Word…", FILE, None, Document, "file-text"), - c("export.html", "Export to HTML…", FILE, None, Document, "file-symlink"), - c("export.rtf", "Export to RTF…", FILE, None, Document, "file-text"), + c("export.text", "Export to text…", FILE, None, Copying, "type"), + c("export.docx", "Export to Word…", FILE, None, Copying, "file-text"), + c("export.html", "Export to HTML…", FILE, None, Copying, "file-symlink"), + c("export.rtf", "Export to RTF…", FILE, None, Copying, "file-text"), c("app.preferences", "Preferences…", EDIT, Some(Shortcut::cmd(",")), Nothing, "settings"), c("tools.js_console", "JavaScript console…", None, Some(Shortcut::cmd("J")), Document, "square-terminal"), c("tools.document_js", "Document JavaScripts…", None, None, Modification, "file-code"), @@ -315,7 +317,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("a11y.report", "Open accessibility report", None, None, Document, "file-text"), c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"), c("a11y.alt_text", "Add alternate text…", None, None, Modification, "image"), - c("export.all_images", "Export all images…", FILE, None, Document, "image"), + c("export.all_images", "Export all images…", FILE, None, Copying, "image"), c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"), c("edit.header_footer.update", "Update header & footer…", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"), c("edit.header_footer.remove", "Remove header & footer", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"), @@ -371,6 +373,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option) Assembly => doc.is_some_and(|d| d.allows_assembly()), Modification => doc.is_some_and(|d| d.allows_modification()), Annotate => doc.is_some_and(|d| d.allows_annotation()), + Copying => doc.is_some_and(|d| d.allows_copying()), FillForms => doc.is_some_and(|d| d.allows_form_filling() && !d.form.is_empty()), Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks.contains(&k)), HasComments => doc.is_some_and(|d| d.allows_modification() && !d.info.annotations.is_empty()), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 985559ef7..2a16c2990 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -312,6 +312,7 @@ impl Document { /// Save image as: image `index` on `page` as a file (extension, bytes). pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { + self.check_copying().map_err(|e| e.to_string())?; let editor = self.editor.as_ref().ok_or("the document can't be read")?; let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; if img.is_form { @@ -427,6 +428,18 @@ impl Document { self.permissions().is_none_or(|p| p.print()) } + /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying + /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the + /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. + pub fn allows_copying(&self) -> bool { + self.permissions().is_none_or(|p| p.copy()) + } + + /// [`Self::allows_copying`], as the error to report when it isn't. + pub fn check_copying(&self) -> Result<(), EditError> { + if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } + } + /// Page changes (insert, delete, rotate, move, extract) are allowed. pub fn allows_assembly(&self) -> bool { self.editable() && self.permissions().is_none_or(|p| p.assemble()) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b978aefde..fdafc7085 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -681,6 +681,32 @@ fn restricted_documents_refuse_changes_unless_opened_by_the_owner() { s.apply(oid, Edit::DeletePages { pages: vec![0] }).unwrap(); } +/// Table 22, bit 5: "Copy or otherwise extract text and graphics" (#568). Snapshots, exports and +/// Save Image As are refused, other permissions are untouched, and the owner password lifts it. +#[test] +fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() { + let bytes = protected("", "owner", -1 ^ 0b1_0000); // everything but copying, no password to open + let mut s = Session::new(); + let id = s.open("r.pdf", None, bytes.clone(), None).unwrap(); + let d = s.get(id).unwrap(); + assert!(!d.allows_copying()); + assert!(d.allows_printing() && d.allows_modification() && d.allows_assembly(), "only copying is withheld"); + assert_eq!(d.check_copying(), Err(EditError::NotPermitted("copying text and images"))); + assert!(d.page_image_file(0, 0).unwrap_err().contains("don't allow copying")); + let extracting = ["edit.snapshot", "export.image", "export.text", "export.docx", "export.html", "export.rtf", "export.all_images"]; + let enabled = |s: &Session, id, c| commands::is_enabled(commands::command(c).unwrap(), s, Some(id)); + for c in extracting { + assert!(!enabled(&s, id, c), "{c} is disabled"); + } + assert!(enabled(&s, id, "edit.find") && enabled(&s, id, "a11y.check"), "searching and accessibility don't copy"); + let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); + assert!(s.get(oid).unwrap().allows_copying()); + assert!(extracting.iter().all(|c| enabled(&s, oid, c))); + // Unencrypted documents allow everything. + let (s, plain) = session_with(1); + assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c))); +} + #[test] fn combining_protected_files_is_refused_clearly() { let s = Session::new(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 7aeb0c205..8d49c7e74 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1687,11 +1687,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context) { if pressed(cmd(Key::CloseBracket)) { view.view_history(true); } - // ⌘C arrives as a Copy event on most platforms. - let copy = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Copy))); - if copy && let Some(text) = view.selected_text() { - ctx.copy_text(text); - } let key = |k| ctx.input(|i| i.key_pressed(k)); if key(Key::Escape) { if view.camera.is_some() || view.camera_done.is_some() { diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 17d2b7c56..d76cf8234 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -133,6 +133,7 @@ impl PdfCraftApp { commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => { tl!("The document's security settings don't allow this change").to_string() } + commands::Needs::Copying if self.active.is_some() => crate::copy_refused().to_string(), commands::Needs::TwoPageView if self.active.is_some() => tl!("Switch to two-page view first to show the cover page").to_string(), _ => tl!("Open a document first").to_string(), }; diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 1dab43e3e..162767913 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -521,6 +521,9 @@ impl crate::PdfCraftApp { /// Edit text & images ▸ Save Image As. pub(crate) fn save_page_image(&mut self, page: usize, index: usize) { let Some((_, id)) = self.active_ids() else { return }; + if !self.may_copy(id) { + return; + } let file = self.session.get(id).map(|d| d.page_image_file(page, index)); match file { Some(Ok((ext, bytes))) => { diff --git a/crates/ui-egui/src/export_ui.rs b/crates/ui-egui/src/export_ui.rs index f1d9b9263..6a82025f4 100644 --- a/crates/ui-egui/src/export_ui.rs +++ b/crates/ui-egui/src/export_ui.rs @@ -221,6 +221,9 @@ impl PdfCraftApp { return; } let Some((_, id)) = self.active_ids() else { return }; + if !self.may_copy(id) { + return; + } let Some(doc) = self.session.get(id) else { return }; let src = doc.export_source(); let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 4ada03012..96149f2e4 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -718,6 +718,9 @@ impl PdfCraftApp { return; } let Some((_, id)) = self.active_ids() else { return }; + if !self.may_copy(id) { + return; + } let Some(doc) = self.session.get(id) else { return }; let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string(); let ext = format.extension(); diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index c16d48344..c4c201654 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1715,6 +1715,7 @@ This document isn't password-protected هذا المستند غير محمي بكلمة مرور Only the document's owner can change its security (open it with the permissions password) مالك المستند وحده يستطيع تغيير أمانه (افتحه بكلمة مرور الأذونات) The document's security settings don't allow this change إعدادات أمان المستند لا تسمح بهذا التغيير + The document's security settings don't allow copying text and images إعدادات أمان المستند لا تسمح بنسخ النصوص والصور Open a document first افتح مستندًا أولًا Security will be removed when you save سيُزال الأمان عند الحفظ Click text or an image to edit it انقر على نص أو صورة لتحريرها diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index e9c8df79d..00458a8b8 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1678,6 +1678,7 @@ This document isn't password-protected Този документ не е защитен с парола Only the document's owner can change its security (open it with the permissions password) Само собственикът на документа може да промени защитата му (отворете го с паролата за разрешения) The document's security settings don't allow this change Настройките за защита на документа не позволяват тази промяна + The document's security settings don't allow copying text and images Настройките за защита на документа не позволяват копиране на текст и изображения Open a document first Първо отворете документ Security will be removed when you save Защитата ще бъде премахната при записване Click text or an image to edit it Щракнете върху текст или изображение, за да го редактирате diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 13c78dace..b500978a6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1646,6 +1646,7 @@ This document isn't password-protected Dieses Dokument ist nicht passwortgeschützt Only the document's owner can change its security (open it with the permissions password) Nur der Eigentümer des Dokuments kann die Sicherheit ändern (öffne es mit dem Berechtigungspasswort) The document's security settings don't allow this change Die Sicherheitseinstellungen des Dokuments erlauben diese Änderung nicht + The document's security settings don't allow copying text and images Die Sicherheitseinstellungen des Dokuments erlauben kein Kopieren von Text und Bildern Open a document first Öffne zuerst ein Dokument Security will be removed when you save Die Sicherheit wird beim Speichern entfernt Click text or an image to edit it Klicke auf Text oder ein Bild, um es zu bearbeiten diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4ff46f752..d7e32d37e 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1719,6 +1719,7 @@ This document isn't password-protected Este documento no está protegido con contraseña Only the document's owner can change its security (open it with the permissions password) Solo el propietario del documento puede cambiar su seguridad (ábrelo con la contraseña de permisos) The document's security settings don't allow this change La configuración de seguridad del documento no permite este cambio + The document's security settings don't allow copying text and images La configuración de seguridad del documento no permite copiar texto ni imágenes Open a document first Abre primero un documento Security will be removed when you save La seguridad se quitará al guardar Click text or an image to edit it Haz clic en un texto o una imagen para editarlo diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b23629c3e..8850006d8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1645,6 +1645,7 @@ This document isn't password-protected Ce document n'est pas protégé par mot de passe Only the document's owner can change its security (open it with the permissions password) Seul le propriétaire du document peut modifier sa protection (ouvrez-le avec le mot de passe d'autorisations) The document's security settings don't allow this change Les paramètres de protection du document n'autorisent pas cette modification + The document's security settings don't allow copying text and images Les paramètres de protection du document n'autorisent pas la copie de texte et d'images Open a document first Ouvrez d'abord un document Security will be removed when you save La protection sera retirée à l'enregistrement Click text or an image to edit it Cliquez sur un texte ou une image pour le modifier diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a3bc965d5..4c8fcc3e7 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1635,6 +1635,7 @@ This document isn't password-protected Ez a dokumentum nincs jelszóval védve Only the document's owner can change its security (open it with the permissions password) Csak a dokumentum tulajdonosa módosíthatja a biztonságát (nyisd meg a jogosultsági jelszóval) The document's security settings don't allow this change A dokumentum biztonsági beállításai nem engedik ezt a módosítást + The document's security settings don't allow copying text and images A dokumentum biztonsági beállításai nem engedik a szöveg és a képek másolását Open a document first Előbb nyiss meg egy dokumentumot Security will be removed when you save A védelem mentéskor megszűnik Click text or an image to edit it Kattints a szövegre vagy egy képre a szerkesztéshez diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 25b117fac..befa486a8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1720,6 +1720,7 @@ This document isn't password-protected Questo documento non è protetto da password Only the document's owner can change its security (open it with the permissions password) Solo il proprietario del documento può modificarne la protezione (aprilo con la password delle autorizzazioni) The document's security settings don't allow this change Le impostazioni di sicurezza del documento non consentono questa modifica + The document's security settings don't allow copying text and images Le impostazioni di sicurezza del documento non consentono di copiare testo e immagini Open a document first Apri prima un documento Security will be removed when you save La protezione verrà rimossa al salvataggio Click text or an image to edit it Fai clic su un testo o un'immagine per modificarlo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 51e3ccf6b..6ca7926fd 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1726,6 +1726,7 @@ This document isn't password-protected この文書はパスワード保護されていません Only the document's owner can change its security (open it with the permissions password) 文書の所有者のみセキュリティを変更できます(権限パスワードで開いてください) The document's security settings don't allow this change 文書のセキュリティ設定により、この変更は許可されていません + The document's security settings don't allow copying text and images 文書のセキュリティ設定により、テキストと画像のコピーは許可されていません Open a document first 先に文書を開いてください Security will be removed when you save セキュリティは保存時に解除されます Click text or an image to edit it テキストまたは画像をクリックして編集 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 621a5486b..a2ffbc013 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1548,6 +1548,7 @@ This document isn't password-protected Este documento não está protegido por senha Only the document's owner can change its security (open it with the permissions password) Somente o proprietário do documento pode alterar sua segurança (abra-o com a senha de permissões) The document's security settings don't allow this change As configurações de segurança do documento não permitem esta alteração + The document's security settings don't allow copying text and images As configurações de segurança do documento não permitem copiar texto e imagens Open a document first Abra um documento primeiro Security will be removed when you save A segurança será removida ao salvar Click text or an image to edit it Clique no texto ou em uma imagem para editá-lo diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e7e1a1881..8f8d29c51 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1716,6 +1716,7 @@ This document isn't password-protected Этот документ не защищён паролем Only the document's owner can change its security (open it with the permissions password) Только владелец документа может изменить его безопасность (откройте его с паролем разрешений) The document's security settings don't allow this change Настройки безопасности документа не разрешают это изменение + The document's security settings don't allow copying text and images Настройки безопасности документа не разрешают копировать текст и изображения Open a document first Сначала откройте документ Security will be removed when you save Безопасность будет удалена при сохранении Click text or an image to edit it Щёлкните текст или изображение, чтобы редактировать diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index bf6e62110..8e07c13f4 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1638,6 +1638,7 @@ action wizard Start ప్రారంభించు This document isn't password-protected ఈ పత్రం పాస్‌వర్డ్‌తో రక్షించబడలేదు Only the document's owner can change its security (open it with the permissions password) పత్రం యజమాని మాత్రమే దాని భద్రతను మార్చగలరు (అనుమతుల పాస్‌వర్డ్‌తో దాన్ని తెరవండి) The document's security settings don't allow this change పత్రం భద్రతా సెట్టింగ్‌లు ఈ మార్పును అనుమతించవు + The document's security settings don't allow copying text and images పత్రం భద్రతా సెట్టింగ్‌లు టెక్స్ట్, చిత్రాలను కాపీ చేయడాన్ని అనుమతించవు Open a document first ముందుగా ఒక పత్రాన్ని తెరవండి Security will be removed when you save మీరు సేవ్ చేసినప్పుడు భద్రత తీసివేయబడుతుంది Click text or an image to edit it సవరించడానికి టెక్స్ట్ లేదా చిత్రంపై క్లిక్ చేయండి diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index ad1954777..a68f2abf2 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1713,6 +1713,7 @@ This document isn't password-protected Цей документ не захищено паролем Only the document's owner can change its security (open it with the permissions password) Лише власник документа може змінювати захист (відкрийте з паролем дозволів) The document's security settings don't allow this change Налаштування захисту документа не дозволяють цю зміну + The document's security settings don't allow copying text and images Налаштування захисту документа не дозволяють копіювати текст і зображення Open a document first Спочатку відкрийте документ Security will be removed when you save Захист буде знято під час збереження Click text or an image to edit it Клацніть текст або зображення для редагування diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d2555e9c9..4d43eb90e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1685,6 +1685,7 @@ This document isn't password-protected 本文档未受密码保护 Only the document's owner can change its security (open it with the permissions password) 只有文档所有者才能更改安全性(请使用权限密码打开) The document's security settings don't allow this change 文档安全设置不允许此更改 + The document's security settings don't allow copying text and images 文档安全设置不允许复制文本和图像 Open a document first 请先打开文档 Security will be removed when you save 保存时将删除安全性设置 Click text or an image to edit it 单击文本或图像进行编辑 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 68e6e81b9..aa1e6355b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1632,6 +1632,7 @@ This document isn't password-protected 本文件未受密碼保護 Only the document's owner can change its security (open it with the permissions password) 只有文件所有者才能更改安全性(請使用許可權密碼開啟) The document's security settings don't allow this change 文件安全設定不允許此更改 + The document's security settings don't allow copying text and images 文件安全設定不允許複製文字和圖片 Open a document first 請先開啟文件 Security will be removed when you save 儲存時將刪除安全性設定 Click text or an image to edit it 按一下文字或圖片進行編輯 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eb28f6cba..0ef6b45a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1986,11 +1986,42 @@ impl PdfCraftApp { } self.tool_keys(i, ctx); canvas::shortcuts(&mut self.views[i], ctx); + self.copy_key(i, ctx); } } } +/// Why nothing can be copied out of the document: its security doesn't allow it (Table 22, +/// bit 5; #568). +pub(crate) fn copy_refused() -> &'static str { + tl!("The document's security settings don't allow copying text and images") +} + impl PdfCraftApp { + /// Whether text and images may be copied out of document `id`; says why not when they may + /// not. Reading aloud and accessibility tools don't ask (assistive technology may read + /// anything, §7.6.4.2). + pub(crate) fn may_copy(&mut self, id: DocId) -> bool { + if self.session.get(id).is_none_or(pdfcraft_engine::Document::allows_copying) { + return true; + } + self.notify(copy_refused().to_string()); + false + } + + /// ⌘C copies the selected text, unless a text field has the keyboard (it copies its own). + fn copy_key(&mut self, i: usize, ctx: &egui::Context) { + // ⌘C arrives as a Copy event on most platforms. + let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy))); + if !copy || ctx.egui_wants_keyboard_input() { + return; + } + let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, v.selected_text()?))) else { return }; + if self.may_copy(id) { + ctx.copy_text(text); + } + } + /// The quick tools' keys, as in Acrobat: V selects, H pans, and holding Space pans until it /// is released. Plain letters, so not while a text field, a form field, a dialog or the /// palette has the keyboard. diff --git a/crates/ui-egui/src/zoom_snap.rs b/crates/ui-egui/src/zoom_snap.rs index 9ee9c702b..76e6729ca 100644 --- a/crates/ui-egui/src/zoom_snap.rs +++ b/crates/ui-egui/src/zoom_snap.rs @@ -63,6 +63,10 @@ impl PdfCraftApp { if view_rect[2] - view_rect[0] < 2.0 || view_rect[3] - view_rect[1] < 2.0 { return; } + // The tool outlives the tab it was chosen on. + if self.views.get(index).map(|v| v.id).is_some_and(|id| !self.may_copy(id)) { + return; + } match self.snapshot(index, page, view_rect) { Ok((w, h)) => { self.notify_fmt("The selected area has been copied ({w} × {h} pixels)", &[("w", &w.to_string()), ("h", &h.to_string())]) diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 62753e841..216bc9c50 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -1631,6 +1631,78 @@ fn restricted_documents_show_a_notice_and_block_page_changes() { assert!(h.query_all_by_label("Not allowed").count() >= 4); } +/// Table 22, bit 5 (#568): a document whose security withholds copying keeps its text and images +/// in. ⌘C, snapshots and exports are refused with a notice; the owner password lifts that. +#[test] +fn copying_out_of_a_document_honours_its_security() { + fn open(password: &'static str) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.system_clipboard = false; + app.open_bytes("no-copy.pdf", None, protected("pw", "owner", -1 ^ 0b1_0000)).unwrap(); + app.submit_password(Some(password.into())); + app + }); + // Select the page's text once its text layer is loaded. + for _ in 0..40 { + h.run_steps(2); + if h.state_mut().views[0].select_all() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("Page 1")); + h + } + fn copy(h: &mut Harness<'static, PdfCraftApp>) -> bool { + h.state_mut().toast = None; + h.event(egui::Event::Copy); + let mut copied = false; + for _ in 0..4 { + h.step(); + copied |= h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == "Page 1")); + } + copied + } + fn snapshot(h: &mut Harness<'static, PdfCraftApp>) { + // Around "Page 1", at (20, 150) on the 200 × 300 pt page. + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let at = |x: f32, y: f32| egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height()); + let (a, b) = (at(15.0, 175.0), at(120.0, 140.0)); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + h.hover_at(b); + h.run_steps(1); + h.drop_at(b); + h.run_steps(3); + } + let refused = Some("The document's security settings don't allow copying text and images"); + + let mut h = open("pw"); + assert!(!copy(&mut h), "⌘C copies nothing"); + assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), refused); + for command in ["edit.snapshot", "export.image", "export.text", "export.docx", "export.html", "export.rtf", "export.all_images"] { + h.state_mut().toast = None; + assert!(!h.state_mut().execute(command), "{command} is refused"); + assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), refused, "{command} says why"); + } + assert_eq!(h.state().dialog, None, "no export dialog"); + // The snapshot tool, chosen on another tab, takes nothing here either. + h.state_mut().set_option("quick", "snapshot").unwrap(); + snapshot(&mut h); + assert!(h.state().last_snapshot.is_none()); + assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), refused); + + let mut h = open("owner"); + assert!(copy(&mut h), "the owner may copy"); + assert!(h.state_mut().execute("edit.snapshot")); + snapshot(&mut h); + assert!(h.state().last_snapshot.is_some()); +} + #[test] fn replace_pages_dialog_swaps_page_content() { let mut app = PdfCraftApp::new();