Issue #568: honour the document's copy permission (Table 22, bit 5) (#914)

* Issue #568: honour the document's copy permission

ISO 32000-2 §7.6.4.1 has readers restrict access to an encrypted file
according to its permissions, and Table 22 bit 5 covers "Copy or otherwise
extract text and graphics". PdfCraft reported the bit (Document Properties
shows "Content copying: Not allowed") but ⌘C copied selected text anyway.

- Engine: Document::allows_copying / check_copying, and Needs::Copying for
  Take a snapshot, Export to image/text/Word/HTML/RTF and Export all images.
  Save Image As refuses too.
- UI: ⌘C moves from canvas::shortcuts to the app, which knows the document.
  Refused copies, snapshots and exports say why. The snapshot tool and the
  exports check again when they act, since the tool stays selected across tabs.
- Automation: text_extract (and the MCP text resources), text_paragraphs,
  doc_export_text/images/all_images, doc_export_office and image_save refuse.

Find, read aloud and the accessibility tools keep working: the spec exempts
assistive technology, and searching isn't copying. Page extraction stays with
the assemble bit, as before. Owners and unencrypted files are unaffected.

* Refresh catalog hashes

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Joel Tony
2026-10-11 13:51:10 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 56c034b0b3
commit acda408192
29 changed files with 222 additions and 30 deletions
+14 -14
View File
@@ -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"
+1 -1
View File
@@ -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() }))
}
+10 -3
View File
@@ -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<Value> = 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<Value> {
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<Value> {
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<Value> {
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")?,
+17
View File
@@ -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);
+10 -7
View File
@@ -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<DocId>)
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()),
+13
View File
@@ -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<u8>), 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())
+26
View File
@@ -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();
-5
View File
@@ -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() {
+1
View File
@@ -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(),
};
+3
View File
@@ -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))) => {
+3
View File
@@ -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();
+3
View File
@@ -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();
+1
View File
@@ -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 انقر على نص أو صورة لتحريرها
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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 Щракнете върху текст или изображение, за да го редактирате
Can't render this file because it contains an unexpected character in line 8 and column 16.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 51.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 60.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 39.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 44.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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 テキストまたは画像をクリックして編集
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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 Щёлкните текст или изображение, чтобы редактировать
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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 సవరించడానికి టెక్స్ట్ లేదా చిత్రంపై క్లిక్ చేయండి
Can't render this file because it contains an unexpected character in line 13 and column 16.
+1
View File
@@ -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 Клацніть текст або зображення для редагування
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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 单击文本或图像进行编辑
Can't render this file because it contains an unexpected character in line 9 and column 16.
+1
View File
@@ -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 按一下文字或圖片進行編輯
Can't render this file because it contains an unexpected character in line 9 and column 16.
+31
View File
@@ -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.
+4
View File
@@ -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())])
+72
View File
@@ -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();