diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 212454973..21bde353e 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -626,13 +626,17 @@ impl Automation { pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { let settings = self.ocr_settings(a)?; let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; - let id = self.doc(a)?.id; + let doc = self.doc(a)?; + // Recognizing needs only permission to change the document; the words it found are + // returned only when copying is allowed too. + let (id, copy) = (doc.id, doc.allows_copying()); let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?; let list: Vec = found .iter() .map(|p| match &p.skipped { Some(why) => json!({ "page": p.page + 1, "skipped": why }), - None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), + None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }), + None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }), }) .collect(); Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index c90240875..522705bb1 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -435,7 +435,7 @@ impl Automation { "doc_revisions" => self.doc_revisions(&a)?, "text_lines" => { 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 lines: Vec = doc @@ -709,7 +709,11 @@ impl Automation { let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy]; let wanted: String = text.split_whitespace().collect(); let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?; - let after = self.doc(&a)?.text_blocks(page); + let doc = self.doc(&a)?; + let after = doc.text_blocks(page); + // Without `text` the paragraph keeps the document's own words: not echoed when + // the document doesn't allow copying them. + let copy = doc.allows_copying(); // Only a paragraph that now shows exactly the requested text is the edited one. A // neighbour that overlaps the old box (overlaid text, a text layer) must not take // its number, and neither must a paragraph the edit merged it into: rewriting that @@ -717,7 +721,10 @@ impl Automation { let edited = block_overlapping(&after, moved) .filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted)); if let Some(b) = edited.and_then(|i| after.get(i)) { - out["paragraph"] = json!({ "text": b.text, "lines": b.lines.len(), "font": b.base_font }); + out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font }); + if copy { + out["paragraph"]["text"] = json!(b.text); + } } let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after); if !stale.is_empty() { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 9ce6b12d4..4fff59c07 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1172,7 +1172,7 @@ pub fn tools() -> Vec { )), t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.") .with(schema(json!({ "enabled": { "type": "boolean" } }), &[])), - t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") + t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text (left out when the document's security doesn't allow copying), word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.") .cmd("ocr.recognize") .with(schema( json!({ @@ -1332,7 +1332,7 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])), - t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.") + t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used, and for a paragraph its text unless the document's security doesn't allow copying. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 0ebf3a9bc..778deaaec 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2130,6 +2130,44 @@ fn protecting_through_tools() { ok(&mut c, "doc_open", json!({ "path": "open.pdf" })); } +/// A document that allows changes but not copying: the tools that return a page's text refuse, +/// or leave the text out where the tool also changes the page (a restyled paragraph, recognized +/// words). +#[test] +fn copy_restricted_text_stays_in_the_document() { + let dir = workdir("copy-restricted-text"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let locked = json!({ "open_password": "open", "permissions_password": "boss", "changes": "any-except-extract" }); + let mut protect = locked.clone(); + protect["doc"] = json!(doc); + ok(&mut a, "doc_protect", protect); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" })); + let re = ok(&mut a, "doc_open", json!({ "path": "locked.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + match a.call("text_lines", &json!({ "doc": re, "page": 1 })) { + Err(ToolError::Failed(m)) => assert!(m.contains("don't allow copying"), "{m}"), + other => panic!("text_lines is refused: {other:?}"), + } + let restyled = ok(&mut a, "text_edit", json!({ "doc": re, "page": 1, "paragraph": 1, "size": 30 })); + assert!(!restyled.to_string().contains("Page"), "{restyled}"); + if ok(&mut a, "ocr_status", json!({}))["available"] != true { + eprintln!("skipped the OCR part: OCR models not installed"); + return; + } + let text = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Recognised words stay private" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_images", json!({ "doc": text, "folder": "scan", "dpi": 150, "pages": [1] })); + let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path(); + let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap(); + let mut protect = locked; + protect["doc"] = json!(scan); + ok(&mut a, "doc_protect", protect); + ok(&mut a, "doc_save", json!({ "doc": scan, "path": "scan.pdf" })); + let scan = ok(&mut a, "doc_open", json!({ "path": "scan.pdf", "password": "open" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "ocr_recognize", json!({ "doc": scan })); + assert!(r["words"].as_u64().unwrap() >= 3, "{r}"); + assert!(r["pages"][0].get("text").is_none() && !r.to_string().to_lowercase().contains("private"), "{r}"); +} + /// A form secured with "fill-sign" changes and no open password (how secured forms are usually /// distributed): its existing signature field can be signed without the permissions password, /// the update keeps the encryption, and the signature validates after reopening. A new field