diff --git a/ROADMAP.md b/ROADMAP.md index 5f15e252c2..e8138a7e3d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -454,6 +454,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged. - **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged. - **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged. +- **2026-10-15 (M7, find and replace in Edit text):** In Edit text mode the floating Find box gains a Replace with row (Edit ▸ Replace…, ⇧⌘H / Ctrl+Shift+H): Replace changes the highlighted match, Replace all every match as one undo step. Each match is rewritten inside its own paragraph through the paragraph rewrite, so fonts, sizes and colours stay and the paragraph rewraps; matches that span paragraphs, sit in OCR text, need another font or lie outside editable paragraphs are left alone and counted. `text_replace` drives the same plan. `edit.find-replace` planned → partial. Estimates unchanged. - **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged. - **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged. - **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged. diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index d3850d1d8f..7695b4ccdd 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -74,6 +74,7 @@ impl Automation { "text_extract" => self.text_extract(&a)?, "text_word_count" => self.text_word_count(&a)?, "text_find" => self.text_find(&a)?, + "text_replace" => self.text_replace(&a)?, "page_rotate" => { let degrees = a.int("degrees")?; if degrees % 90 != 0 { diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index b57bce0176..b89dbc483d 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -1,6 +1,6 @@ use super::dispatch::Content; use super::error::{Result, ToolError, failed}; -use super::json::{Args, one_based}; +use super::json::{Args, one_based, summary}; use super::{Arc, Automation, DocId, HashMap, PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind, Value, commands, json, tools}; /// Default and maximum resolution for `page_render`. @@ -356,6 +356,103 @@ impl Automation { Ok(out) } + /// Find and replace (Edit text ▸ Replace): every match (or the first) rewritten inside its + /// own paragraph through the same engine plan as the window's Replace all, one undo step. + /// The page text is searched too (as `text_find` does), so matches no paragraph holds are + /// counted as not editable and the counts add up to what `text_find` reports. + pub(crate) fn text_replace(&mut self, a: &Args) -> Result { + self.text_replace_with(a, &mut extract_parallel) + } + + pub(crate) fn text_replace_with( + &mut self, + a: &Args, + extract: &mut impl FnMut(&Arc>, Option>, &[usize]) -> PageTexts, + ) -> Result { + let doc = self.doc(a)?; + let (id, n) = (doc.id, doc.info.pages.len()); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let query = pdfcraft_engine::find_replace::Replace { + find: a.str("find")?.to_owned(), + with: a.str("replace")?.to_owned(), + case_sensitive: a.opt_bool("case_sensitive")?.unwrap_or(false), + whole_words: a.opt_bool("whole_words")?.unwrap_or(false), + }; + let all = a.opt_bool("all")?.unwrap_or(true); + let mut pages = pages; + pages.sort_unstable(); + pages.dedup(); + // Pages whose text the find bar couldn't read, reported with the plan, not dropped. + let mut unreadable: Vec<(usize, String)> = Vec::new(); + let (which, found) = if all { + let mut found = std::collections::BTreeMap::new(); + for (p, text) in self.page_text_results(id, &pages, &mut *extract)? { + match text { + Ok(text) => { + found.insert(p, text.find_opts(&query.find, query.case_sensitive, query.whole_words).len()); + } + Err(e) => unreadable.push((p, e)), + } + } + unreadable.sort(); + (Some(pdfcraft_engine::find_replace::Which::All), Some(found)) + } else { + // The first match the find bar shows, and that one only: pages are read in order until + // one holds a match, and a page that can't be read stops the search there. + let mut texts = Vec::new(); + let (mut start, mut batch) = (0, FIND_FIRST_BATCH.max(1)); + 'search: while start < pages.len() { + let end = pages.len().min(start.saturating_add(batch)); + let mut results: std::collections::HashMap = + self.page_text_results(id, &pages[start..end], &mut *extract)?.into_iter().collect(); + for p in &pages[start..end] { + match results.remove(p) { + Some(Ok(text)) if text.find_opts_limited(&query.find, query.case_sensitive, query.whole_words, 1).is_empty() => {} + Some(Ok(text)) => { + texts.push((*p, text)); + break 'search; + } + Some(Err(e)) => { + unreadable.push((*p, e)); + break 'search; + } + None => {} + } + } + start = end; + batch = batch.saturating_mul(2).min(FIND_MAX_BATCH); + } + (pdfcraft_engine::find_replace::Which::first_in(texts.iter().map(|(p, t)| (*p, t.as_ref())), &query), None) + }; + let mut plan = match which { + Some(which) => self.doc(a)?.plan_replace(&pages, &query, which, found.as_ref()).map_err(failed)?, + None => { + // Nothing to replace: still the permission and query checks of a plan. + self.doc(a)?.plan_replace(&[], &query, pdfcraft_engine::find_replace::Which::All, None).map_err(failed)? + } + }; + for (p, e) in &unreadable { + plan.unreadable_page(*p, e); + } + let mut out = match plan.edit.clone() { + Some(edit) => self.apply(a, edit)?, + None => summary(self.doc(a)?), + }; + let skipped: serde_json::Map = plan.skipped.iter().map(|(why, k)| (why.key().to_owned(), json!(k))).collect(); + out["replaced"] = json!(plan.replaced); + out["changed"] = json!(plan.edit.is_some()); + out["pages"] = json!(plan.pages.iter().map(|p| p + 1).collect::>()); + out["skipped"] = json!(plan.skipped_total()); + out["skipped_by"] = Value::Object(skipped); + if !plan.refusals.is_empty() { + out["refusals"] = json!(plan.refusals); + } + Ok(out) + } + pub(crate) fn command_list(&self, a: &Args) -> Result { let active = match a.opt_int("doc")? { Some(_) => Some(self.doc(a)?.id), diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 408cb7cac0..1b857b6c29 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -707,3 +707,27 @@ fn text_extract_html_has_one_budget_across_pages() { assert_eq!(capped["pages"][1]["html"], "", "no HTML after the budget ran out: {capped}"); assert_eq!(capped["pages"][1]["styles_incomplete"], true); } + +/// Replace through the tool when a page's text can't be read for the find bar: the page is named +/// and counted as refused, in the result, and the readable pages are still replaced. +#[test] +fn replace_all_reports_a_page_whose_text_cannot_be_read() { + let mut a = Automation::new(); + let id = search_document(&mut a, 3); + let mut extract = |bytes: &Arc>, password, pages: &[usize]| { + extract_parallel(bytes, password, pages) + .into_iter() + .map(|(p, result)| (p, if p == 1 { Err("synthetic page 2 failure".to_owned()) } else { result })) + .collect() + }; + let r = a.text_replace_with(&Args(&json!({ "doc": id.0, "find": "Page", "replace": "Sheet" })), &mut extract).unwrap(); + assert_eq!(r["replaced"], 3, "{r}"); + assert_eq!((r["skipped"].clone(), r["skipped_by"]["refused"].clone()), (json!(1), json!(1)), "{r}"); + let why = r["refusals"][0].as_str().unwrap(); + assert!(why.starts_with("page 2:") && why.contains("synthetic page 2 failure"), "{why}"); + // Replacing the first match stops at an unreadable page before it: nothing is replaced. + let r = + a.text_replace_with(&Args(&json!({ "doc": id.0, "find": "Page", "replace": "Sheet", "all": false, "pages": [2, 3] })), &mut extract).unwrap(); + assert_eq!((r["replaced"].clone(), r["changed"].clone(), r["skipped_by"]["refused"].clone()), (json!(0), json!(false), json!(1)), "{r}"); + assert!(r["refusals"][0].as_str().unwrap().starts_with("page 2:"), "{r}"); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 219d84be08..469a244838 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -276,6 +276,17 @@ pub fn tools() -> Vec { "start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." }, "skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." }, "stop_at_limit": { "type": "boolean", "description": "Stop at the limit-th match without looking for another (default false). The answer then has stopped_at_limit; when true, complete is false and next is only where to resume, so following it may find nothing more." } }), &["doc", "query"])), + t("text_replace", "Replace text", "Find and replace (Edit text ▸ Replace): each match of find, with the find bar's rules (whitespace-normalised; case-insensitive unless case_sensitive; whole_words: no letter or digit may adjoin a match), is rewritten inside its own paragraph, which keeps its fonts, sizes and colours and rewraps; the replacement takes the style of the run it lands in. A longer replacement is measured in the paragraph's fonts and is not shrunk or hyphenated: one row grows right to the page's margin, then wraps; several rows keep their width. New rows go below and nothing else moves, so they can overlap what follows, unreported; a word wider than the width overruns it. replace may be empty (deletes the match). all (default true) replaces every match on pages (default all) as one undo step; false only the first match the find bar shows (page text order; when no paragraph holds it nothing is replaced and it is reported as not_editable). Matches are left alone and counted in skipped_by when they span two paragraphs (spans_paragraphs), are invisible text over a scan (scanned_text), would need another font because the paragraph's own can't draw the new text (missing_glyphs), the paragraph can't be rewritten (refused, with refusals), or the page text holds them outside any editable paragraph, such as form fields, comments or text inside a form XObject (not_editable, counted when all). A page whose text can't be read is counted once as refused and named in refusals. Returns replaced, skipped, skipped_by, the 1-based pages changed and whether the document changed.") + .cmd("edit.replace") + .with(schema( + json!({ + "doc": doc(), "find": { "type": "string", "minLength": 1 }, "replace": { "type": "string" }, + "pages": pages("to search (default: all)"), + "case_sensitive": { "type": "boolean" }, "whole_words": { "type": "boolean" }, + "all": { "type": "boolean", "description": "Every match (default) or only the first." }, + }), + &["doc", "find", "replace"], + )), t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.") .cmd("page.rotate") .with(schema( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 39807b595a..e55ca7ed7a 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -4397,6 +4397,37 @@ fn accessibility_review_sidecar_tracks_human_decisions_for_exact_pdf_revision() } } +/// Find and replace through text_replace: every match (one undo step), the first only, the find +/// options, and counts that add up to what text_find reports. +#[test] +fn replacing_text_through_tools() { + let dir = workdir("replace-text"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "page" }))["count"].as_u64(); + let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "page", "replace": "Sheet" })); + assert_eq!((r["replaced"].as_u64(), r["skipped"].as_u64(), r["changed"].as_bool()), (Some(3), Some(0), Some(true))); + assert_eq!(found, r["replaced"].as_u64(), "what text_find found is what was replaced"); + assert_eq!(r["pages"], json!([1, 2, 3])); + assert_eq!(page_text(&mut a, doc), ["Sheet 1", "Sheet 2", "Sheet 3"]); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Replace all"); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"], "one undo step"); + // The first match only, on the pages asked for. + let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "page", "replace": "Sheet", "all": false, "pages": [2, 3] })); + assert_eq!((r["replaced"].as_u64(), r["pages"].clone()), (Some(1), json!([2]))); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Sheet 2", "Page 3"]); + // Case-sensitive and whole words follow the find bar's options; nothing found changes nothing. + let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "page", "replace": "x", "case_sensitive": true })); + assert_eq!((r["replaced"].as_u64(), r["changed"].as_bool()), (Some(0), Some(false))); + let r = ok(&mut a, "text_replace", json!({ "doc": doc, "find": "Pag", "replace": "x", "whole_words": true })); + assert_eq!(r["replaced"].as_u64(), Some(0)); + // An empty replacement deletes the match. + ok(&mut a, "text_replace", json!({ "doc": doc, "find": "Page ", "replace": "" })); + assert_eq!(page_text(&mut a, doc), ["1", "Sheet 2", "3"]); + assert!(matches!(a.call("text_replace", &json!({ "doc": doc, "find": "x" })), Err(ToolError::InvalidArgs(_))), "replace is required"); + assert!(matches!(a.call("text_replace", &json!({ "doc": doc, "find": "x", "replace": "y", "pages": [9] })), Err(ToolError::InvalidArgs(_)))); +} + #[test] fn editing_existing_text_through_tools() { let dir = workdir("edit-text"); diff --git a/crates/edit/src/find_replace_rows_tests.rs b/crates/edit/src/find_replace_rows_tests.rs new file mode 100644 index 0000000000..a268c2d271 --- /dev/null +++ b/crates/edit/src/find_replace_rows_tests.rs @@ -0,0 +1,75 @@ +//! A replacement through the run sources must leave the rows it does not reach as they were. +use std::sync::Arc; + +use pdfcraft_cos::{Document, SaveOptions, write_incremental}; + +use super::*; + +fn page(content: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn content(doc: &Document) -> String { + let bytes = write_incremental(doc, &SaveOptions::default()).unwrap(); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + let list = match doc.resolve(p.dict.get(b"Contents").unwrap()).as_ref() { + Object::Array(a) => a.clone(), + _ => vec![p.dict.get(b"Contents").unwrap().clone()], + }; + let mut out = Vec::new(); + for o in &list { + if let Object::Stream(s) = &*doc.resolve(o) { + out.extend(s.decoded().unwrap_or_default()); + } + } + String::from_utf8_lossy(&out).into_owned() +} + +#[test] +fn replace_keeps_untouched_rows() { + let mut doc = page("BT /F1 10 Tf 12 TL 100 700 Td [(Hello) -300 (World)] TJ T* (second row here) Tj ET"); + let block = text_blocks(&doc, 0).unwrap().swap_remove(0); + let start = block.editor_text.find("here").expect("the second row's word is in the paragraph"); + let (text, sources) = block.replaced(std::slice::from_ref(&(start..start + 4)), "there").expect("the range is on boundaries"); + let style = BlockStyle { sources, ..Default::default() }; + rewrite_block(&mut doc, 0, 0, Some(&text), &style).unwrap(); + let after = content(&doc); + assert!(after.contains("-300"), "the first row keeps its TJ kerning: {after}"); +} + +/// The rewrite keeps every space typed, so a deleted word must not leave its two neighbouring +/// spaces behind; a run of spaces the paragraph had is left as it was. +#[test] +fn a_deleted_word_leaves_one_space() { + let doc = page("BT /F1 10 Tf 100 700 Td (one two three) Tj ET"); + let block = text_blocks(&doc, 0).unwrap().swap_remove(0); + assert_eq!(block.editor_text, "one two three"); + let two = block.editor_text.find("two").unwrap(); + let (text, _) = block.replaced(std::slice::from_ref(&(two..two + 3)), "").unwrap(); + assert_eq!(text, "one three"); + let one = block.editor_text.find("one").unwrap(); + let (text, _) = block.replaced(std::slice::from_ref(&(one..one + 3)), "").unwrap(); + assert_eq!(text, " two three"); + let (text, _) = block.replaced(std::slice::from_ref(&(two..two + 3)), "2").unwrap(); + assert_eq!(text, "one 2 three"); +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index d0f6d2c863..f02930a8e5 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -61,8 +61,9 @@ mod linebreak; pub mod paragraph_align; pub mod text; pub use text::{ - BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping, - clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, + BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, RunSources, TextBlock, TextLine, TextSession, + block_overlapping, clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, + text_blocks, text_lines, }; pub mod printer_marks; pub use printer_marks::{PrinterMarks, add_printer_marks}; @@ -93,6 +94,8 @@ use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_ use place::{Reach, check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint}; pub use place::{add_type3_fonts, stamp, stamp_with_fonts, undrawable_message}; +#[cfg(test)] +mod find_replace_rows_tests; #[cfg(test)] mod marked_isolation_tests; #[cfg(test)] diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 6c514515d0..e9bf8370f8 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -8358,6 +8358,72 @@ fn unchanged_runs_between_two_edits_keep_their_styles() { assert_eq!(style(&after, "Italic footnote"), style(&before, "Italic footer")); } +/// Find and replace: two words replaced in one paragraph of several runs. Each replacement takes +/// the run of the first character it replaces, and every run between them keeps its own style. +#[test] +fn replacing_words_apart_in_a_paragraph_keeps_every_run_between_them() { + let original = mixed_region_page(); + let block = text::text_blocks(&original, 0).unwrap()[1].clone(); + assert_eq!(block.editor_text, "About\nPlain bold text.\nItalic footer"); + let about = block.editor_text.find("About").unwrap(); + let text_word = block.editor_text.find("text").unwrap(); + let (draft, sources) = block.replaced(&[about..about + 5, text_word..text_word + 4], "Info").unwrap(); + assert_eq!(draft, "Info\nPlain bold Info.\nItalic footer"); + let pieces: Vec<&str> = sources.iter().map(|(r, _)| &draft[r.clone()]).collect(); + assert_eq!(pieces, ["Info", "\nPlain ", "bold", " Info.", "\nItalic footer"]); + let before = text::text_lines(&original, 0).unwrap(); + let source_of = |word: &str| before.iter().position(|l| l.text.contains(word)).unwrap(); + let runs: Vec = sources.iter().map(|(_, s)| *s).collect(); + assert_eq!(runs, [source_of("About"), source_of("Plain"), source_of("bold"), source_of(" text."), source_of("Italic")]); + let mut doc = original.clone(); + let style = text::BlockStyle { sources, ..Default::default() }; + assert_eq!(text::rewrite_block(&mut doc, 0, 1, Some(&draft), &style).unwrap().substituted, None); + let reopened = reopen(&doc); + assert_eq!(text::text_blocks(&reopened, 0).unwrap()[1].editor_text, draft); + let lines = text::text_lines(&reopened, 0).unwrap(); + let look = |l: &text::TextLine| (l.base_font.clone(), l.color); + let run = |word: &str| lines.iter().find(|l| l.text.contains(word)).unwrap_or_else(|| panic!("{word}: {lines:?}")); + assert_eq!(look(run("Info")), look(&before[source_of("About")]), "the first replacement takes About's run"); + assert_eq!(look(run("Plain")), look(&before[source_of("Plain")])); + assert_eq!(look(run("bold")), look(&before[source_of("bold")]), "the run between the two replacements keeps its style"); + assert_eq!(look(run("Info.")), look(&before[source_of(" text.")])); + assert_eq!(look(run("Italic")), look(&before[source_of("Italic")])); + // A replaced word that is a run of its own keeps that run's style, where aligning the old and + // new text (the inline editor's way) gives a changed word the run before it. + let bold = block.editor_text.find("bold").unwrap(); + let (draft, sources) = block.replaced(std::slice::from_ref(&(bold..bold + 4)), "heavy").unwrap(); + let mut doc = original.clone(); + text::rewrite_block(&mut doc, 0, 1, Some(&draft), &text::BlockStyle { sources, ..Default::default() }).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + let heavy = lines.iter().find(|l| l.text.contains("heavy")).unwrap_or_else(|| panic!("{lines:?}")); + assert_eq!(look(heavy), look(&before[source_of("bold")]), "the replacement keeps the bold run's style"); + // Out of order, overlapping or inside a character: no draft. + assert!(block.replaced(&[text_word..text_word + 4, about..about + 5], "x").is_none()); + assert!(block.replaced(&[0..3, 2..5], "x").is_none()); + assert!(block.replaced(&[0..0, 3..4], "x").is_none(), "an empty range"); + assert_eq!(block.replaced(&[], "x").map(|(d, _)| d), Some(block.editor_text.clone())); +} + +/// Sources that don't cover the text in order, or name a run of another paragraph, are refused +/// and change nothing. +#[test] +fn style_sources_must_cover_the_text_with_the_paragraphs_runs() { + let original = mixed_region_page(); + let block = text::text_blocks(&original, 0).unwrap()[1].clone(); + let draft = block.editor_text.replace("About", "Info"); + let own = block.lines[0]; + let other = text::text_blocks(&original, 0).unwrap()[0].lines[0]; + let len = draft.len(); + for sources in + [vec![(0..4, own)], vec![(0..4, own), (5..len, own)], vec![(0..len, other)], vec![(0..len + 1, own)], vec![(4..len, own), (0..4, own)]] + { + let mut doc = original.clone(); + let style = text::BlockStyle { sources: sources.clone(), ..Default::default() }; + assert!(text::rewrite_block(&mut doc, 0, 1, Some(&draft), &style).is_err(), "{sources:?}"); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[1].editor_text, block.editor_text); + } +} + #[test] fn disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically() { let mut doc = mixed_region_page(); diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index ac65b83cbe..b58c41303f 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -39,7 +39,7 @@ mod rewrite; #[cfg(test)] use blocks::wrap; pub use blocks::{ - BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked, + BlockKey, BlockStyle, RangeStyle, ReadingBlocks, RunSources, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked, replace_block, text_blocks, }; use blocks::{kept_words, paragraph_separator, row_break}; diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 3ab0240f54..db4fa4f2db 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -61,14 +61,7 @@ impl TextBlock { let old: Vec<_> = self.editor_text.chars().collect(); let new: Vec<_> = draft.char_indices().collect(); let chars: Vec<_> = new.iter().map(|(_, c)| *c).collect(); - let mut original = Vec::with_capacity(old.len()); - let mut span = 0; - for (byte, _) in self.editor_text.char_indices() { - while self.spans.get(span).is_some_and(|(r, _)| byte >= r.end) { - span += 1; - } - original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i)); - } + let original = self.char_sources(); let mut sources = Vec::with_capacity(new.len()); let slices = |text: &[char]| words(text).into_iter().map(|w| text.get(w).unwrap_or_default().to_vec()).collect::>(); let (old_words, new_words) = (slices(&old), slices(&chars)); @@ -229,6 +222,79 @@ fn replaced(original: &[usize], at: usize, old: &[char], new: &[char], out: &mut } } +/// Byte ranges of a paragraph's text and the source run (an index into [`text_lines`]) each one +/// keeps the style of, in order ([`TextBlock::spans`], [`BlockStyle::sources`]). +pub type RunSources = Vec<(std::ops::Range, usize)>; + +impl TextBlock { + /// The source run (an index into [`text_lines`]) of each character of `editor_text`. A + /// character no span covers (the line break between two rows, a space put between two runs) + /// belongs to the run that follows it. + fn char_sources(&self) -> Vec { + let mut original = Vec::with_capacity(self.editor_text.len()); + let mut span = 0; + for (byte, _) in self.editor_text.char_indices() { + while self.spans.get(span).is_some_and(|(r, _)| byte >= r.end) { + span += 1; + } + original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i)); + } + original + } + + /// `editor_text` with each of `ranges` (byte ranges in it, in order and not overlapping) put + /// in `with`, and the source run each byte range of the result keeps the style of + /// ([`BlockStyle::sources`]): every character outside the ranges its own, a replacement the + /// run of the first character it replaces. Spaces a replacement puts side by side become + /// one (deleting a word leaves one space between its neighbours), while a run of spaces the + /// paragraph already had stays, since the rewrite keeps every space typed. `None` when a + /// range is out of order, overlaps another or does not fall on character boundaries. + pub fn replaced(&self, ranges: &[std::ops::Range], with: &str) -> Option<(String, RunSources)> { + let mut end = 0; + for r in ranges { + if r.start < end || r.start >= r.end || self.editor_text.get(r.clone()).is_none() { + return None; + } + end = r.end; + } + // One run per stretch of the result: a piece from the same run right after it joins it. + fn extend(spans: &mut Vec<(std::ops::Range, usize)>, start: usize, len: usize, source: usize) { + match spans.last_mut() { + _ if len == 0 => {} + Some((range, last)) if *last == source && range.end == start => range.end = start.saturating_add(len), + _ => spans.push((start..start.saturating_add(len), source)), + } + } + let mut draft = String::with_capacity(self.editor_text.len()); + let mut spans = Vec::new(); + let mut next = ranges.iter().peekable(); + // Right after a replacement: a space here would join one the draft ends with. + let mut joined = false; + let spacing = |c: char| c != '\n' && c != '\r' && paragraph_separator(c); + for ((byte, ch), source) in self.editor_text.char_indices().zip(self.char_sources()) { + if let Some(r) = next.peek() + && byte >= r.start + { + if byte == r.start { + extend(&mut spans, draft.len(), with.len(), source); + draft.push_str(with); + } + if byte.saturating_add(ch.len_utf8()) >= r.end { + next.next(); + joined = true; + } + continue; + } + if std::mem::take(&mut joined) && spacing(ch) && draft.chars().next_back().is_some_and(spacing) { + continue; + } + extend(&mut spans, draft.len(), ch.len_utf8(), source); + draft.push(ch); + } + Some((draft, spans)) + } +} + /// How a listing of [`text_blocks`] records a paragraph so that it can be found again. /// /// The index of a paragraph is its position in a list regrouped from geometry on every read, so @@ -461,6 +527,12 @@ pub struct BlockStyle { /// For an invisible paragraph ([`TextBlock::invisible`]): fill its old area with this colour /// (RGB 0–1) before the new text, hiding the scanned word beneath it. pub cover: Option<[f64; 3]>, + /// The source run each byte range of the new text keeps the style of (indexes into + /// [`text_lines`], as in [`TextBlock::spans`]), covering the text in order. Empty: + /// [`TextBlock::edited_spans`] works it out by aligning the old and new text, where a changed + /// word takes the run before it. Find and replace sets it ([`TextBlock::replaced`]): there a + /// replacement takes the run of the text it replaces, however many matches the paragraph has. + pub sources: RunSources, /// For an invisible paragraph whose formatting nobody asked to change: cover and redraw only /// the words the draft changes, and leave the rest as the scan shows them (#3313). The session /// sets this; a caller that sets it should also give `cover`. diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 6bb09b8891..a1beb291f9 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -807,9 +807,8 @@ fn wrap_styled_rows( fn apply_range_styles<'a>( first: &TextLine, - block: &TextBlock, lines: &[TextLine], - draft: &str, + sources: Vec<(std::ops::Range, usize)>, ranges: &[&'a RangeStyle], style: &BlockStyle, ) -> StyledLines<'a> { @@ -817,7 +816,7 @@ fn apply_range_styles<'a>( let mut styled_spans = Vec::new(); let mut underlines = HashMap::new(); let mut overrides = HashMap::new(); - for (span, source) in block.edited_spans(draft) { + for (span, source) in sources { let mut edges = vec![span.start, span.end]; for range in ranges { for edge in [range.range.start, range.range.end] { @@ -1591,6 +1590,25 @@ fn splice_with_marker( data } +/// The source run each part of the draft is styled from: the ones given, which must cover the draft +/// in order, on character boundaries, from the block's runs, or else the edit's own. +fn draft_sources(block: &TextBlock, draft: &str, style: &BlockStyle) -> Result, usize)>, EditError> { + if style.sources.is_empty() { + return Ok(block.edited_spans(draft)); + } + let mut at = 0; + for (range, source) in &style.sources { + if range.start != at || range.start >= range.end || draft.get(range.clone()).is_none() || !block.lines.contains(source) { + return Err(EditError::Invalid("the text's style sources must cover it in order with the paragraph's own runs".into())); + } + at = range.end; + } + if at != draft.len() { + return Err(EditError::Invalid("the text's style sources must cover it in order with the paragraph's own runs".into())); + } + Ok(style.sources.clone()) +} + /// Rewrite visual rows while keeping the source style on each surviving character. Resource /// names may differ between Unicode subsets of the same face; encoding searches those subsets. pub(crate) fn rewrite_styled_block( @@ -1609,7 +1627,8 @@ pub(crate) fn rewrite_styled_block( } let ranges = validated_ranges(draft, style)?; let source_count = lines.len(); - let styled = apply_range_styles(first, block, lines, draft, &ranges, style); + let sources = draft_sources(block, draft, style)?; + let styled = apply_range_styles(first, lines, sources, &ranges, style); let lines = styled.lines.as_slice(); let styled_spans = styled.spans; let underlines = styled.underlines; @@ -1711,7 +1730,7 @@ pub(crate) fn rewrite_styled_block( // A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and // glyph widths stay exactly as they were instead of being rebuilt from the text state. A row // aligned on its own is reached; the rows around it are not. - let plain = BlockStyle { ranges: Vec::new(), aligns: Vec::new(), ..style.clone() } == BlockStyle::default(); + let plain = BlockStyle { ranges: Vec::new(), sources: Vec::new(), aligns: Vec::new(), ..style.clone() } == BlockStyle::default(); let mut kept = if plain { untouched_rows(block, draft, &row_ranges) .into_iter() diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 1996bcd793..dfbc33d658 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -198,6 +198,9 @@ pub const COMMANDS: &[CommandSpec] = &[ ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"), ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"), c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"), + // Find and replace, in Edit text mode (the Find box's Replace row). Not ⌘H/Ctrl+H: macOS + // takes ⌘H to hide the app, and Ctrl+H is Read mode on Windows and Linux (as in Acrobat). + c("edit.replace", "Replace…", EDIT, Some(Shortcut::cmd_shift("H")), Modification, "replace"), c("edit.advanced_search", "Advanced search…", EDIT, Some(Shortcut::cmd_shift("F")), Document, "search"), c("view.palette", "Find tools and commands…", VIEW, Some(Shortcut::cmd("K")), Nothing, "search"), // Page display: View ▸ Page display and the rail's button list the base layouts as radios and diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index be1cb1a0a4..bfbdc5f7e5 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -167,8 +167,16 @@ pub const EDIT_LABELS: &[&str] = &[ /// The names of the batches built below L7, which an interface shows in the same places as /// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives /// already worded in the interface's language. -pub const BATCH_LABELS: &[&str] = - &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL, "Style"]; +pub const BATCH_LABELS: &[&str] = &[ + "Set document title", + "Mark differences", + "Detect form fields", + "Run JavaScript", + "Recognize text", + field_table::LABEL, + "Style", + crate::find_replace::REPLACE_ALL, +]; impl Edit { /// Label for the Edit menu and history ("Undo Rotate pages"). diff --git a/crates/engine/src/find_replace.rs b/crates/engine/src/find_replace.rs new file mode 100644 index 0000000000..006a53bb29 --- /dev/null +++ b/crates/engine/src/find_replace.rs @@ -0,0 +1,346 @@ +//! Find and replace in the page text (Edit text ▸ Replace, the `text_replace` tool). +//! +//! Each match is rewritten inside its own paragraph ([`crate::Document::text_blocks`]) through +//! [`Edit::EditTextBlock`], so the paragraph keeps its fonts, sizes and colours and rewraps in its +//! box. The paragraph's `editor_text` is matched with the find bar's own rules +//! ([`pdfcraft_render::text::find_in_text`]) and the replacement takes the style of the run it +//! lands in ([`pdfcraft_edit::TextBlock::replaced`]); every other run keeps its own. +//! +//! A longer replacement changes the paragraph only, measured with its own fonts' widths, and +//! nothing is shrunk or hyphenated to fit. A paragraph of one row grows to the right, up to 36 pt +//! from the crop box (a centred line stays in its own box), then wraps; a paragraph of several rows +//! keeps its width and rewraps at spaces. New rows go below the last one at the paragraph's line +//! spacing and nothing else on the page moves, so they can run over what is below, or off the page, +//! without being reported. A word wider than the width stays whole, past the box. +//! +//! A match is left alone, and counted, when it runs from one paragraph into the next, lies in +//! invisible text over a scan, would need another font (the paragraph's own can't draw the new +//! text, see [`pdfcraft_edit::LineEdit::substituted`]) or is not page text the paragraphs hold +//! (a form field, a comment, text drawn inside a form XObject). Each paragraph is rewritten on a +//! copy first, so the edit returned is exactly what will apply: [`Edit::Batch`] for Replace all, +//! one undo step. + +use std::collections::BTreeMap; +use std::ops::Range; + +use crate::edit::check_permission; +use crate::{Document, Edit, EditError, guard}; + +/// What to look for and what to put in its place, with the find bar's options. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Replace { + pub find: String, + /// May be empty: the match is deleted. + pub with: String, + pub case_sensitive: bool, + pub whole_words: bool, +} + +/// Which matches to replace. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Which { + /// Every match on the pages. + All, + /// The match the find bar highlights on `page`: the centres of its first and last glyphs, in + /// view space (points from the top-left of the displayed page, y down: the text layer's). + At { page: usize, start: [f32; 2], end: [f32; 2] }, +} + +impl Which { + /// The first match the find bar shows (page text order: pages in order, then reading order), + /// as an [`Which::At`] on its first and last glyphs, or `None` when no page has one. `texts` + /// are the pages' text, in page order. Replacing "the first match" is replacing this one: if + /// no paragraph holds it, [`Document::plan_replace`] reports it as not editable and replaces + /// nothing, rather than passing it for a later match that is. + pub fn first_in<'a>(texts: impl IntoIterator, query: &Replace) -> Option { + texts.into_iter().find_map(|(page, text)| { + let found = text.find_opts_limited(&query.find, query.case_sensitive, query.whole_words, 1).into_iter().next()?; + let centre = |g: usize| text.glyphs.get(g).map(|g| [(g.rect[0] + g.rect[2]) / 2.0, (g.rect[1] + g.rect[3]) / 2.0]); + Some(Which::At { page, start: centre(found.start)?, end: centre(found.end.saturating_sub(1))? }) + }) + } +} + +/// Why a match was left as it is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Skip { + /// It runs from one paragraph into the next. + SpansParagraphs, + /// It is invisible text over a scanned image (recognized text). + ScannedText, + /// The paragraph's font can't draw the new text; another font would have stepped in. + MissingGlyphs, + /// The paragraph couldn't be rewritten (see [`Replacement::refusals`]). + Refused, + /// It is not in the page's editable text: a form field, a comment, text inside a form XObject. + NotEditable, +} + +impl Skip { + /// How the reason is said after a count ("2 skipped: …"); an interface translates it. + pub fn wording(self) -> &'static str { + match self { + Skip::SpansParagraphs => "spans two paragraphs", + Skip::ScannedText => "scanned text", + Skip::MissingGlyphs => "the font can't draw it", + Skip::Refused => "the paragraph can't be rewritten", + Skip::NotEditable => "not editable text", + } + } + + /// A short name for tools. + pub fn key(self) -> &'static str { + match self { + Skip::SpansParagraphs => "spans_paragraphs", + Skip::ScannedText => "scanned_text", + Skip::MissingGlyphs => "missing_glyphs", + Skip::Refused => "refused", + Skip::NotEditable => "not_editable", + } + } +} + +/// Every [`Skip::wording`], for an interface to translate. +pub const SKIP_WORDINGS: &[&str] = + &["spans two paragraphs", "scanned text", "the font can't draw it", "the paragraph can't be rewritten", "not editable text"]; + +/// What a replacement would do: the edit (apply it with [`crate::Session::apply`]) and the counts. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Replacement { + /// `None` when nothing can be replaced. + pub edit: Option, + /// Matches replaced. + pub replaced: usize, + /// The pages (0-based, ascending) where something was replaced. + pub pages: Vec, + /// Matches left alone, by reason. + pub skipped: BTreeMap, + /// What the engine said about the paragraphs it refused (at most [`MAX_REFUSALS`]). + pub refusals: Vec, +} + +/// Refusal messages kept in a [`Replacement`]. +pub const MAX_REFUSALS: usize = 10; + +impl Replacement { + pub fn skipped_total(&self) -> usize { + self.skipped.values().fold(0, |a, b| a.saturating_add(*b)) + } + + /// Say that `page`'s text could not be read for the find bar (`why`): its matches can't be + /// counted, so one is counted as [`Skip::Refused`] and the page is named in the refusals. + pub fn unreadable_page(&mut self, page: usize, why: &str) { + refused(self, 1, page, format!("its text could not be read: {why}")); + } + + fn skip(&mut self, why: Skip, n: usize) { + if n > 0 { + let count = self.skipped.entry(why).or_insert(0); + *count = count.saturating_add(n); + } + } +} + +/// The label of Replace all in the history (one of [`crate::BATCH_LABELS`]). +pub const REPLACE_ALL: &str = "Replace all"; + +impl Document { + /// The edit that replaces `query`'s matches on `pages` (0-based; others are ignored), and + /// what it replaces and leaves. `found` is how many matches the find bar has on each page: + /// those no paragraph holds are counted as [`Skip::NotEditable`] (`None`: not counted). The + /// document is not changed; the same permission check as Edit text applies. + pub fn plan_replace( + &self, + pages: &[usize], + query: &Replace, + which: Which, + found: Option<&BTreeMap>, + ) -> Result { + let editor = self.editor.as_ref().ok_or_else(|| EditError::ReadOnly(self.read_only_reason.clone().unwrap_or_default()))?; + // What Edit text checks: the document's permissions. + let probe = Edit::EditTextBlock { page: 0, block: 0, text: String::new(), style: Default::default() }; + if let Some(p) = editor.cos.permissions() { + check_permission(&probe, &p)?; + } + if query.find.split_whitespace().next().is_none() { + return Err(EditError::Invalid("there is nothing to find".into())); + } + let n = self.info.pages.len(); + let mut pages: Vec = pages.iter().copied().filter(|p| *p < n).collect(); + pages.sort_unstable(); + pages.dedup(); + if let Which::At { page, .. } = which { + pages.retain(|p| *p == page); + } + let mut out = Replacement::default(); + let mut work = editor.cos.clone(); + let mut edits = Vec::new(); + for page in pages { + // A page whose text can't be read is reported, and the others still replaced. + let blocks = match pdfcraft_edit::text_blocks(&work, page) { + Ok(blocks) => blocks, + Err(e) => { + refused(&mut out, found.and_then(|f| f.get(&page)).copied().unwrap_or(0), page, e.to_string()); + continue; + } + }; + let matches: Vec>> = blocks.iter().map(|b| find(&b.editor_text, query, usize::MAX)).collect(); + // Paragraph `k`'s matches to replace now. + let mut chosen: BTreeMap>> = BTreeMap::new(); + match which { + Which::All => { + let held = matches.iter().fold(0usize, |a, m| a.saturating_add(m.len())); + let spanning = spanning(&blocks, query); + out.skip(Skip::SpansParagraphs, spanning); + if let Some(f) = found.and_then(|f| f.get(&page)) { + out.skip(Skip::NotEditable, f.saturating_sub(held.saturating_add(spanning))); + } + chosen.extend(matches.into_iter().enumerate().filter(|(_, m)| !m.is_empty())); + } + Which::At { start, end, .. } => match at(self, &work, page, &blocks, &matches, start, end) { + Ok((k, m)) => { + chosen.insert(k, vec![m]); + } + Err(why) => out.skip(why, 1), + }, + } + // Last paragraph first, which usually leaves those before it where they were. That is + // checked, not assumed: once a paragraph on this page is rewritten, the page is read + // again and a paragraph that is no longer what the match was found in is refused. + let mut rewrote = false; + for (k, ranges) in chosen.into_iter().rev() { + let Some(block) = blocks.get(k) else { continue }; + let count = ranges.len(); + if rewrote { + let same = pdfcraft_edit::text_blocks(&work, page) + .ok() + .is_some_and(|now| now.get(k).is_some_and(|b| b.editor_text == block.editor_text)); + if !same { + refused(&mut out, count, page, "the page's paragraphs moved after an earlier replacement".into()); + continue; + } + } + if block.invisible { + out.skip(Skip::ScannedText, count); + continue; + } + let Some((text, sources)) = block.replaced(&ranges, &query.with) else { + out.skip(Skip::Refused, count); + continue; + }; + if text == block.editor_text { + // The replacement is what was there. + out.replaced = out.replaced.saturating_add(count); + continue; + } + let style = pdfcraft_edit::BlockStyle { sources, ..Default::default() }; + let mut trial = work.clone(); + match guard(|| pdfcraft_edit::rewrite_block(&mut trial, page, k, Some(&text), &style)) { + Ok(Ok(pdfcraft_edit::LineEdit { substituted: None })) => { + work = trial; + rewrote = true; + edits.push(Edit::EditTextBlock { page, block: k, text, style }); + out.replaced = out.replaced.saturating_add(count); + if !out.pages.contains(&page) { + out.pages.push(page); + } + } + Ok(Ok(pdfcraft_edit::LineEdit { substituted: Some(_) })) => out.skip(Skip::MissingGlyphs, count), + Ok(Err(e)) => refused(&mut out, count, page, e.to_string()), + Err(panic) => refused(&mut out, count, page, panic), + } + } + } + out.pages.sort_unstable(); + out.edit = match (which, edits.len()) { + (_, 0) => None, + (Which::All, _) => Some(Edit::Batch { label: REPLACE_ALL.into(), edits }), + (Which::At { .. }, _) => edits.pop(), + }; + Ok(out) + } +} + +fn find(text: &str, query: &Replace, limit: usize) -> Vec> { + pdfcraft_render::text::find_in_text(text, &query.find, query.case_sensitive, query.whole_words, limit) +} + +fn refused(out: &mut Replacement, count: usize, page: usize, why: String) { + out.skip(Skip::Refused, count); + if out.refusals.len() < MAX_REFUSALS { + out.refusals.push(format!("page {}: {why}", page.saturating_add(1))); + } +} + +/// Matches that start in one paragraph and end in a later one: the page's paragraphs read one +/// after another (a line break between two), as the find bar reads the page. +fn spanning(blocks: &[pdfcraft_edit::TextBlock], query: &Replace) -> usize { + let mut joined = String::new(); + let mut ends = Vec::with_capacity(blocks.len()); + for b in blocks { + if !joined.is_empty() { + joined.push('\n'); + } + joined.push_str(&b.editor_text); + ends.push(joined.len()); + } + // Which paragraph a byte of `joined` is in (a separator counts with the paragraph after it). + let paragraph = |byte: usize| ends.partition_point(|end| *end <= byte); + find(&joined, query, usize::MAX).into_iter().filter(|r| paragraph(r.start) != paragraph(r.end.saturating_sub(1))).count() +} + +/// The paragraph match the find bar's match at `start` … `end` (view space) is: the paragraph +/// whose box holds both, and its match nearest `start`. +fn at( + doc: &Document, + cos: &pdfcraft_cos::Document, + page: usize, + blocks: &[pdfcraft_edit::TextBlock], + matches: &[Vec>], + start: [f32; 2], + end: [f32; 2], +) -> Result<(usize, Range), Skip> { + let info = doc.info.pages.get(page).ok_or(Skip::NotEditable)?; + let view = |x: f64, y: f64| info.user_to_view(x as f32, y as f32); + // A paragraph's box in view space, a little larger so a glyph's centre on its edge is inside. + let holds = |b: &pdfcraft_edit::TextBlock, p: [f32; 2]| { + let corners = [view(b.rect[0], b.rect[1]), view(b.rect[2], b.rect[3]), view(b.rect[0], b.rect[3]), view(b.rect[2], b.rect[1])]; + let pad = (b.size as f32 * 0.3).clamp(1.0, 12.0); + let (x0, x1) = corners.iter().fold((f32::MAX, f32::MIN), |(a, b), c| (a.min(c[0]), b.max(c[0]))); + let (y0, y1) = corners.iter().fold((f32::MAX, f32::MIN), |(a, b), c| (a.min(c[1]), b.max(c[1]))); + p[0] >= x0 - pad && p[0] <= x1 + pad && p[1] >= y0 - pad && p[1] <= y1 + pad + }; + let lines = pdfcraft_edit::text_lines(cos, page).unwrap_or_default(); + // Where a match's first character is drawn, estimated along its run. + let place = |b: &pdfcraft_edit::TextBlock, r: &Range| -> Option<[f32; 2]> { + let (span, line) = b.spans.iter().find(|(s, _)| s.contains(&r.start)).or_else(|| b.spans.iter().find(|(s, _)| s.start >= r.start))?; + let line = lines.get(*line)?; + let run = b.editor_text.get(span.clone())?; + let before = b.editor_text.get(span.start..r.start.max(span.start))?.chars().count() as f64; + let total = run.chars().count().max(1) as f64; + let along = ((before + 0.5) / total).clamp(0.0, 1.0); + let x = line.rect[0] + along * (line.rect[2] - line.rect[0]); + Some(view(x, (line.rect[1] + line.rect[3]) / 2.0)) + }; + let distance = |p: [f32; 2]| (p[0] - start[0]).hypot(p[1] - start[1]); + let mut best: Option<(f32, usize, Range)> = None; + for (k, b) in blocks.iter().enumerate().filter(|(_, b)| holds(b, start)) { + for r in matches.get(k).into_iter().flatten() { + let d = place(b, r).map_or(f32::MAX, distance); + if best.as_ref().is_none_or(|(e, ..)| d < *e) { + best = Some((d, k, r.clone())); + } + } + } + match best { + Some((_, k, r)) if blocks.get(k).is_some_and(|b| holds(b, end)) => Ok((k, r)), + Some(_) => Err(Skip::SpansParagraphs), + None if blocks.iter().any(|b| holds(b, start)) && blocks.iter().any(|b| holds(b, end) && !holds(b, start)) => Err(Skip::SpansParagraphs), + None => Err(Skip::NotEditable), + } +} + +#[cfg(test)] +mod rewrite_order_tests; +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/find_replace/rewrite_order_tests.rs b/crates/engine/src/find_replace/rewrite_order_tests.rs new file mode 100644 index 0000000000..d13c8c9cf9 --- /dev/null +++ b/crates/engine/src/find_replace/rewrite_order_tests.rs @@ -0,0 +1,48 @@ +//! Replacing in several paragraphs of one page: each is checked against the page as it is after +//! the earlier rewrites, and a paragraph that still reads as it was found is replaced. +use std::sync::Arc; + +use super::*; +use crate::{DocId, Session}; + +fn page_with(content: &str) -> Vec { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn open(bytes: Vec) -> (Session, DocId) { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(bytes), None).expect("opens"); + (s, id) +} + +#[test] +fn replacing_in_every_paragraph_of_a_page_still_replaces_each_one() { + let (mut s, id) = open(page_with("BT /F1 12 Tf 72 700 Td (red apple) Tj 0 -60 Td (red pear) Tj 0 -60 Td (red plum) Tj ET")); + let q = Replace { find: "red".into(), with: "green".into(), ..Default::default() }; + let plan = s.get(id).unwrap().plan_replace(&[0], &q, Which::All, None).unwrap(); + assert_eq!(plan.replaced, 3, "refusals: {:?}", plan.refusals); + assert!(plan.refusals.is_empty(), "{:?}", plan.refusals); + let edit = plan.edit.expect("an edit"); + s.apply(id, edit).unwrap(); + let texts: Vec = s.get(id).unwrap().text_blocks(0).into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["green apple", "green pear", "green plum"]); +} diff --git a/crates/engine/src/find_replace/tests.rs b/crates/engine/src/find_replace/tests.rs new file mode 100644 index 0000000000..72f474f822 --- /dev/null +++ b/crates/engine/src/find_replace/tests.rs @@ -0,0 +1,341 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use super::*; +use crate::{DocId, Session}; + +/// A one-page document with `content` and two fonts: `F1` Helvetica, `F2` Helvetica-Bold, and +/// `F3` a subset of a face no computer has, with glyphs for `a` and `b` only. +fn pdf(content: &str) -> Vec { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R /F3 7 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+NoSuchFace /FirstChar 97 /LastChar 99 /Widths [500 520 0] /Encoding /WinAnsiEncoding >>" + .into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn open(bytes: Vec) -> (Session, DocId) { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("replace.pdf", None, Arc::new(bytes), None).expect("opens"); + (s, id) +} + +fn query(find: &str, with: &str) -> Replace { + Replace { find: find.into(), with: with.into(), ..Default::default() } +} + +/// Plan and apply; the plan's counts. +fn replace_all(s: &mut Session, id: DocId, q: &Replace) -> Replacement { + let pages: Vec = (0..s.get(id).unwrap().info.pages.len()).collect(); + let plan = s.get(id).unwrap().plan_replace(&pages, q, Which::All, None).unwrap(); + if let Some(edit) = plan.edit.clone() { + s.apply(id, edit).unwrap(); + } + plan +} + +fn texts(s: &Session, id: DocId, page: usize) -> Vec { + s.get(id).unwrap().text_blocks(page).into_iter().map(|b| b.text).collect() +} + +/// The page text as the find bar reads it (the text layer's glyphs). +fn page_text(s: &Session, id: DocId, page: usize) -> pdfcraft_render::PageText { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + out.text.map(|t| t.as_ref().clone()).unwrap_or_default() +} + +#[test] +fn a_replaced_word_keeps_its_paragraphs_font_and_size() { + let (mut s, id) = open(pdf("BT /F1 14 Tf 72 700 Td (The quick brown fox) Tj ET")); + let plan = replace_all(&mut s, id, &query("quick", "slow")); + assert_eq!((plan.replaced, plan.pages.clone(), plan.skipped_total()), (1, vec![0], 0)); + assert_eq!(texts(&s, id, 0), ["The slow brown fox"]); + let lines = s.get(id).unwrap().text_lines(0); + assert_eq!(lines.len(), 1); + assert_eq!((lines[0].font.as_str(), lines[0].base_font.as_str(), lines[0].size), ("F1", "Helvetica", 14.0)); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Replace all")); +} + +/// A red word and a bold name inside a paragraph keep their own style when words around them +/// are replaced; a replacement inside the red word stays red. +#[test] +fn a_mixed_style_paragraph_keeps_the_other_runs_styles() { + let content = "BT /F1 12 Tf 72 700 Td 0 g (Please pay the invoice ) Tj 1 0 0 rg (now) Tj 0 g (, before the end of the month,) Tj ET +BT /F1 12 Tf 72 686 Td 0 g (as agreed with ) Tj /F2 12 Tf (Ada Lovelace) Tj /F1 12 Tf (.) Tj ET"; + let (mut s, id) = open(pdf(content)); + assert_eq!(texts(&s, id, 0).len(), 1, "one paragraph of five runs"); + let q = Replace { whole_words: true, ..query("the", "THE") }; + let plan = replace_all(&mut s, id, &q); + assert_eq!(plan.replaced, 3); + let plan = replace_all(&mut s, id, &query("now", "today")); + assert_eq!(plan.replaced, 1); + assert_eq!(texts(&s, id, 0), ["Please pay THE invoice today, before THE end of THE month, as agreed with Ada Lovelace."]); + let lines = s.get(id).unwrap().text_lines(0); + let run = |word: &str| lines.iter().find(|l| l.text.contains(word)).unwrap_or_else(|| panic!("no run with {word:?}: {lines:?}")); + assert_eq!(run("today").color, [1.0, 0.0, 0.0], "the replacement takes the red run it lands in"); + assert_eq!(run("today").text.trim(), "today", "and only it is red"); + assert_eq!(run("Ada").base_font, "Helvetica-Bold"); + assert_eq!(run("Ada").text.trim(), "Ada Lovelace"); + assert_eq!(run("invoice").color, [0.0, 0.0, 0.0]); + assert_eq!(run("invoice").base_font, "Helvetica"); +} + +#[test] +fn replace_all_across_pages_is_one_undo_step() { + let (mut s, id) = open(crate::tests::fixture(3)); + let before = s.get(id).unwrap().bytes.clone(); + let plan = replace_all(&mut s, id, &query("page", "Sheet")); + assert_eq!((plan.replaced, plan.pages.clone()), (3, vec![0, 1, 2])); + assert!(matches!(&plan.edit, Some(Edit::Batch { edits, .. }) if edits.len() == 3)); + for p in 0..3 { + assert_eq!(texts(&s, id, p), [format!("Sheet {}", p + 1)]); + } + s.undo(id).unwrap(); + for p in 0..3 { + assert_eq!(texts(&s, id, p), [format!("Page {}", p + 1)]); + } + assert_eq!(s.get(id).unwrap().can_undo(), None, "one step undid all three pages"); + assert_eq!(s.get(id).unwrap().bytes, before, "the original bytes are back"); +} + +#[test] +fn whole_words_and_case_follow_the_find_options() { + let content = "BT /F1 12 Tf 72 700 Td (cat Cats concat CAT) Tj ET"; + let count = |q: Replace| { + let (s, id) = open(pdf(content)); + s.get(id).unwrap().plan_replace(&[0], &q, Which::All, None).unwrap().replaced + }; + assert_eq!(count(query("cat", "dog")), 4); + assert_eq!(count(Replace { whole_words: true, ..query("cat", "dog") }), 2); + assert_eq!(count(Replace { case_sensitive: true, ..query("cat", "dog") }), 2); + assert_eq!(count(Replace { case_sensitive: true, whole_words: true, ..query("CAT", "dog") }), 1); + let (mut s, id) = open(pdf(content)); + replace_all(&mut s, id, &Replace { whole_words: true, ..query("cat", "dog") }); + assert_eq!(texts(&s, id, 0), ["dog Cats concat dog"]); + // An empty replacement deletes the match. + replace_all(&mut s, id, &query(" concat", "")); + assert_eq!(texts(&s, id, 0), ["dog Cats dog"]); +} + +#[test] +fn a_match_spanning_two_paragraphs_is_skipped_and_counted() { + let content = "BT /F1 12 Tf 72 700 Td (The first ends here) Tj ET BT /F1 12 Tf 72 500 Td (second starts there) Tj ET"; + let (mut s, id) = open(pdf(content)); + assert_eq!(texts(&s, id, 0).len(), 2); + let plan = replace_all(&mut s, id, &query("here second", "x")); + assert_eq!(plan.replaced, 0); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::SpansParagraphs, 1)])); + assert!(plan.edit.is_none()); + assert_eq!(texts(&s, id, 0), ["The first ends here", "second starts there"]); +} + +/// The subset in `F3` has glyphs for `a` and `b` only, from a face no computer has: a `c` would +/// be drawn in Helvetica instead. That paragraph is left alone and counted, the other replaced. +#[test] +fn a_character_the_font_cannot_draw_is_skipped_and_counted() { + let content = "BT /F3 12 Tf 72 700 Td (ab ba) Tj ET BT /F1 12 Tf 72 500 Td (ab in Helvetica) Tj ET"; + let (mut s, id) = open(pdf(content)); + let before = texts(&s, id, 0); + assert_eq!(before, ["ab ba", "ab in Helvetica"]); + let plan = replace_all(&mut s, id, &query("ab", "cb")); + assert_eq!(plan.replaced, 1); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::MissingGlyphs, 1)])); + assert_eq!(texts(&s, id, 0), ["ab ba", "cb in Helvetica"]); + let lines = s.get(id).unwrap().text_lines(0); + assert!(lines.iter().filter(|l| l.text.contains("ab ba")).all(|l| l.font == "F3"), "the subset paragraph keeps its font: {lines:?}"); + // What the subset can draw still goes in. + let plan = replace_all(&mut s, id, &query("ab ba", "ba ab")); + assert_eq!((plan.replaced, plan.skipped_total()), (1, 0)); + assert_eq!(texts(&s, id, 0)[0], "ba ab"); +} + +/// The styled rewrite (a paragraph of several runs) reports the missing character as the +/// one-style rewrite above does. +#[test] +fn a_character_the_font_cannot_draw_is_skipped_in_a_paragraph_of_several_runs() { + let (mut s, id) = open(pdf("BT /F3 12 Tf 72 700 Td (ab ) Tj /F1 12 Tf (and Helvetica) Tj ET")); + let plan = replace_all(&mut s, id, &query("ab", "cb")); + assert_eq!(plan.replaced, 0); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::MissingGlyphs, 1)])); + assert!(plan.edit.is_none()); +} + +/// A character Helvetica has no code for is never written as a box or a `?`: whether another font +/// has it or none does, the match is left alone and reported. +#[test] +fn a_replacement_outside_the_fonts_encoding_is_reported_and_changes_nothing() { + for with in ["\u{65E5}\u{672C}", "\u{1F600}"] { + let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (one cat) Tj ET")); + let plan = replace_all(&mut s, id, &query("cat", with)); + assert_eq!((plan.replaced, plan.skipped_total()), (0, 1), "{with}: {plan:?}"); + assert!(plan.edit.is_none(), "{with}"); + assert_eq!(texts(&s, id, 0), ["one cat"]); + } +} + +/// The rects of the page's text lines holding `word`. +fn line_rects(s: &Session, id: DocId, word: &str) -> Vec<[f64; 4]> { + s.get(id).unwrap().text_lines(0).into_iter().filter(|l| l.text.contains(word)).map(|l| l.rect).collect() +} + +/// A paragraph of several rows keeps its width and gains rows below; what is under it stays where +/// it was, so the new rows run over it. +#[test] +fn a_longer_replacement_adds_rows_and_moves_nothing_else() { + let content = + "BT /F1 12 Tf 14 TL 72 700 Td (alpha beta gamma delta) Tj T* (epsilon zeta eta theta) Tj ET BT /F1 12 Tf 72 650 Td (next paragraph) Tj ET"; + let (mut s, id) = open(pdf(content)); + assert_eq!(texts(&s, id, 0).len(), 2); + let right = s.get(id).unwrap().text_blocks(0)[0].rect[2]; + let next = line_rects(&s, id, "next paragraph"); + let with = "a much longer replacement that takes several more rows than the paragraph has"; + let plan = replace_all(&mut s, id, &query("beta", with)); + assert_eq!((plan.replaced, plan.skipped_total()), (1, 0)); + let lines = s.get(id).unwrap().text_lines(0); + let rows: Vec<_> = lines.iter().filter(|l| !l.text.contains("next paragraph")).collect(); + assert!(rows.len() >= 5, "{rows:?}"); + assert!(rows.iter().all(|l| l.rect[2] <= right + 1.0), "the rows keep the width: {rows:?}"); + assert!(rows.iter().any(|l| l.rect[1] < next[0][1]), "the new rows go past what was below: {rows:?}"); + assert_eq!(line_rects(&s, id, "next paragraph"), next); +} + +/// One row grows to the right up to the margin, then wraps; a word wider than that stays whole. +#[test] +fn a_longer_replacement_grows_one_row_to_the_margin() { + let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET")); + replace_all(&mut s, id, &query("short", "a line far wider than the word it took the place of")); + assert_eq!(s.get(id).unwrap().text_lines(0).len(), 1); + let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET")); + replace_all(&mut s, id, &query("short", &"many words in a row ".repeat(8))); + let lines = s.get(id).unwrap().text_lines(0); + assert!(lines.len() > 1 && lines.iter().all(|l| l.rect[2] <= 595.0 - 36.0 + 1.0), "{lines:?}"); + let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (short) Tj ET")); + replace_all(&mut s, id, &query("short", &"w".repeat(80))); + let lines = s.get(id).unwrap().text_lines(0); + assert_eq!(lines.len(), 1); + assert!(lines[0].rect[2] > 595.0, "the word runs past the page: {lines:?}"); +} + +/// A match can run over a row's end hyphen, typed as the find bar reads it (the break is a space); +/// Replace all then folds case, joins the rows and counts what the find bar counts. The word read +/// unhyphenated is also a find-bar match, but no paragraph's text holds it: left alone and counted. +#[test] +fn case_insensitive_replace_all_takes_a_match_across_a_hyphenated_row_end() { + let content = "BT /F1 12 Tf 14 TL 72 700 Td (An EXAM-) Tj T* (PLE and an Exam-) Tj T* (ple to end) Tj ET"; + let (mut s, id) = open(pdf(content)); + assert_eq!(texts(&s, id, 0), ["An EXAMPLE and an Example to end"]); + let text = page_text(&s, id, 0); + let whole = query("example", "sample"); + let found = BTreeMap::from([(0, text.find_opts(&whole.find, false, false).len())]); + assert_eq!(found[&0], 2); + let none = s.get(id).unwrap().plan_replace(&[0], &whole, Which::All, Some(&found)).unwrap(); + assert_eq!((none.replaced, none.skipped_total(), none.edit.is_none()), (0, 2, true)); + let q = query("exam- ple", "sample"); + let found = BTreeMap::from([(0, text.find_opts(&q.find, false, false).len())]); + assert_eq!(found[&0], 2); + let plan = s.get(id).unwrap().plan_replace(&[0], &q, Which::All, Some(&found)).unwrap(); + assert_eq!((plan.replaced, plan.skipped_total()), (2, 0)); + s.apply(id, plan.edit.unwrap()).unwrap(); + assert_eq!(texts(&s, id, 0), ["An sample and an sample to end"]); +} + +#[test] +fn invisible_text_over_a_scan_is_skipped() { + let (mut s, id) = open(pdf("BT 3 Tr /F1 12 Tf 72 700 Td (recognized words) Tj ET")); + let plan = replace_all(&mut s, id, &query("words", "text")); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::ScannedText, 1)])); + assert!(plan.edit.is_none()); +} + +/// What the find bar counts on a page beyond what the paragraphs hold is reported as not +/// editable text (a form field, a comment), so the counts add up to what was highlighted. +#[test] +fn matches_the_paragraphs_dont_hold_are_counted_as_not_editable() { + let (s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (one cat) Tj ET")); + let found = BTreeMap::from([(0, 3)]); + let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), Which::All, Some(&found)).unwrap(); + assert_eq!(plan.replaced, 1); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 2)])); +} + +/// Replace (one match): the highlighted one, found by where the find bar's glyphs are, even when +/// the paragraph holds the word twice. +#[test] +fn the_highlighted_match_is_the_one_replaced() { + let (mut s, id) = open(pdf("BT /F1 12 Tf 72 700 Td (cat and cat) Tj ET BT /F1 12 Tf 72 500 Td (no match) Tj ET")); + let text = page_text(&s, id, 0); + let found = text.find("cat"); + assert_eq!(found.len(), 2); + let centre = |g: usize| { + let r = text.glyphs[g].rect; + [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0] + }; + let second = &found[1]; + let which = Which::At { page: 0, start: centre(second.start), end: centre(second.end - 1) }; + let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), which, None).unwrap(); + assert_eq!(plan.replaced, 1); + assert!(matches!(&plan.edit, Some(Edit::EditTextBlock { block: 0, .. })), "one paragraph edit, not a batch: {:?}", plan.edit); + s.apply(id, plan.edit.unwrap()).unwrap(); + assert_eq!(texts(&s, id, 0), ["cat and dog", "no match"]); + // A point in no paragraph is not editable text. + let away = Which::At { page: 0, start: [500.0, 20.0], end: [510.0, 20.0] }; + let plan = s.get(id).unwrap().plan_replace(&[0], &query("cat", "dog"), away, None).unwrap(); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 1)])); +} + +#[test] +fn first_replaces_only_the_first_match_in_document_order() { + let (mut s, id) = open(crate::tests::fixture(2)); + let q = query("page", "Sheet"); + let texts_of: Vec<_> = (0..2).map(|p| page_text(&s, id, p)).collect(); + let which = Which::first_in(texts_of.iter().enumerate(), &q).expect("the find bar has a match"); + assert!(matches!(which, Which::At { page: 0, .. })); + let plan = s.get(id).unwrap().plan_replace(&[0, 1], &q, which, None).unwrap(); + assert_eq!((plan.replaced, plan.pages.clone()), (1, vec![0])); + s.apply(id, plan.edit.unwrap()).unwrap(); + assert_eq!((texts(&s, id, 0), texts(&s, id, 1)), (vec!["Sheet 1".to_string()], vec!["Page 2".to_string()])); + assert!(Which::first_in(texts_of.iter().enumerate(), &query("absent", "x")).is_none()); +} + +/// The first match is the find bar's, not the first one a paragraph holds: when it is a match no +/// paragraph holds (a word split by a line-end hyphen), it is reported and nothing is replaced, +/// not the later paragraph that has the word whole. +#[test] +fn first_does_not_skip_a_match_no_paragraph_holds_for_a_later_one() { + let content = "BT /F1 12 Tf 14 TL 72 700 Td (An EXAM-) Tj T* (PLE to end) Tj ET BT /F1 12 Tf 72 400 Td (a second example) Tj ET"; + let (s, id) = open(pdf(content)); + let q = query("example", "sample"); + let text = page_text(&s, id, 0); + assert_eq!(text.find_opts("example", false, false).len(), 2, "the find bar has both"); + let which = Which::first_in([(0, &text)], &q).unwrap(); + let plan = s.get(id).unwrap().plan_replace(&[0], &q, which, None).unwrap(); + assert_eq!((plan.replaced, plan.edit.is_none()), (0, true), "{plan:?}"); + assert_eq!(plan.skipped, BTreeMap::from([(Skip::NotEditable, 1)])); + assert_eq!(texts(&s, id, 0).last().unwrap(), "a second example"); +} + +#[test] +fn nothing_to_find_is_refused() { + let (s, id) = open(crate::tests::fixture(1)); + assert!(matches!(s.get(id).unwrap().plan_replace(&[0], &query(" ", "x"), Which::All, None), Err(EditError::Invalid(_)))); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 97bf07b7af..bf5e4da7a4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -32,6 +32,7 @@ mod error; pub mod export; pub mod field_table; mod fill_sign; +pub mod find_replace; mod history; pub mod js; mod layers; diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 9f3439e158..e717e5ac61 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -93,6 +93,15 @@ pub fn fold_case(s: &str) -> String { out } +/// Byte ranges in `text` where `needle` occurs, with the find bar's rules ([`PageText::find_opts`]): +/// case folded unless `case_sensitive`, any run of whitespace matching one space, and with +/// `whole_words` only where no letter or digit adjoins the match. At most `limit` ranges, in order +/// and not overlapping. Find and replace matches a paragraph's text with it, so what the find bar +/// highlights is what gets replaced. +pub fn find_in_text(text: &str, needle: &str, case_sensitive: bool, whole_words: bool, limit: usize) -> Vec> { + search::find_in_text(text, needle, case_sensitive, whole_words, limit) +} + /// The text of one page, in content order, with line structure. #[derive(Clone, Debug, Default, PartialEq)] pub struct PageText { diff --git a/crates/render/src/text/search.rs b/crates/render/src/text/search.rs index 42df5c0a83..0d0b95d5e5 100644 --- a/crates/render/src/text/search.rs +++ b/crates/render/src/text/search.rs @@ -50,16 +50,7 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b let texts: Vec = page.glyphs.iter().map(|g| fold(&g.text)).collect(); let divided: Vec = (0..texts.len()).map(|i| divides(page, &texts, i)).collect(); let readings: &[Hyphen] = if divided.contains(&true) { &[Hyphen::Written, Hyphen::Joined, Hyphen::Kept] } else { &[Hyphen::Written] }; - // KMP reuses a matched prefix instead of comparing a long query again at every position. - // The table is bounded by the page's already materialized character count. - let mut prefix = vec![0; needle.len()]; - let mut matched = 0; - for (i, &c) in needle.iter().enumerate().skip(1) { - matched = advance(&needle, &prefix, matched, c); - if let Some(slot) = prefix.get_mut(i) { - *slot = matched; - } - } + let prefix = prefix_table(&needle); let mut hits: Vec<(Range, usize)> = Vec::new(); for (r, &reading) in readings.iter().enumerate() { let (chars, zero_width_space) = characters(page, &texts, ÷d, reading); @@ -92,6 +83,58 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b kept } +/// [`super::find_in_text`]: the same search over a string (find and replace matches a paragraph's +/// text with it), as the byte ranges of the characters each hit covers. Hyphenated line ends do +/// not arise in a string, so there is the one reading. +pub(super) fn find_in_text(text: &str, query: &str, sensitive: bool, whole_words: bool, limit: usize) -> Vec> { + if limit == 0 { + return Vec::new(); + } + let fold = |s: &str| if sensitive { ligature_letters(s.to_owned()) } else { fold_case(&ligature_letters(s.to_owned())) }; + let query: String = fold(query).chars().filter(|&c| !is_invisible(c)).collect(); + let needle: Vec = query.split_whitespace().collect::>().join(" ").chars().collect(); + if needle.is_empty() { + return Vec::new(); + } + // Each character is owned by its index in the string, turned back into bytes at the end. + let mut chars: Vec<(char, usize)> = Vec::new(); + let mut zero_width_space: Vec = Vec::new(); + let mut pending_zero_width_space = false; + for (index, (_, c)) in text.char_indices().enumerate() { + for c in fold(c.encode_utf8(&mut [0; 4])).chars() { + if is_invisible(c) { + pending_zero_width_space |= c == '\u{200B}'; + continue; + } + let c = if c.is_whitespace() { ' ' } else { c }; + if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') { + chars.push((c, index)); + zero_width_space.push(pending_zero_width_space && c != ' '); + pending_zero_width_space = false; + } + } + } + let starts: Vec = text.char_indices().map(|(at, _)| at).chain(std::iter::once(text.len())).collect(); + search(&chars, &zero_width_space, &needle, &prefix_table(&needle), whole_words, limit) + .into_iter() + .filter_map(|hit| Some(*starts.get(hit.start)?..*starts.get(hit.end)?)) + .collect() +} + +/// KMP reuses a matched prefix instead of comparing a long query again at every position. The +/// table is bounded by the page's already materialized character count. +fn prefix_table(needle: &[char]) -> Vec { + let mut prefix = vec![0; needle.len()]; + let mut matched = 0; + for (i, &c) in needle.iter().enumerate().skip(1) { + matched = advance(needle, &prefix, matched, c); + if let Some(slot) = prefix.get_mut(i) { + *slot = matched; + } + } + prefix +} + /// Whether glyph `i` ends its line with a hyphen that follows a letter of its word, and the next /// line starts with a letter. `texts` are the glyphs' folded texts; the invisible characters find /// skips are passed over on both sides. diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index 15e091a5b9..77349bf1a1 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1123,6 +1123,33 @@ fn turkish_dotted_and_dotless_i_match_in_any_case() { assert_eq!(lower.find("KIŞ"), vec![13..16]); } +/// Find and replace matches a paragraph's text with the find bar's own rules: the same +/// matches as the page search, as byte ranges of the text (multi-byte letters included). +#[test] +fn text_search_agrees_with_the_page_search() { + let mut v = Vec::new(); + word(&mut v, "İSTANBUL", 10.0, 10.0, 7.0); + word(&mut v, "cat", 80.0, 10.0, 7.0); + word(&mut v, "Cats", 110.0, 10.0, 7.0); + word(&mut v, "cat.", 150.0, 10.0, 7.0); + let page = layout(v); + let text = page.plain_text(); + assert_eq!(text, "İSTANBUL cat Cats cat."); + for (needle, case, whole) in + [("cat", false, false), ("cat", true, false), ("cat", false, true), ("istanbul cat", false, false), ("CATS", true, true)] + { + let glyphs: Vec = page.find_opts(needle, case, whole).into_iter().map(|r| page.text_of(r)).collect(); + let bytes: Vec<&str> = find_in_text(&text, needle, case, whole, usize::MAX).into_iter().filter_map(|r| text.get(r)).collect(); + assert_eq!(glyphs, bytes, "{needle:?} case {case} whole {whole}"); + } + assert_eq!(find_in_text(&text, "cat", false, true, usize::MAX), vec![10..13, 19..22]); + assert_eq!(find_in_text(&text, "istanbul", false, false, usize::MAX), vec![0..9], "İ is two bytes"); + assert_eq!(find_in_text("a\n b", "a b", false, false, usize::MAX), vec![0..5], "any whitespace run is one space"); + assert_eq!(find_in_text("cat cat", "cat", false, false, 1), vec![0..3]); + assert!(find_in_text("cat", " ", false, false, usize::MAX).is_empty()); + assert!(find_in_text("cat", "cat", false, false, 0).is_empty()); +} + #[test] fn greek_sigma_forms_match_without_changing_exact_search_or_glyph_ranges() { let mut v = Vec::new(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 251c29cdeb..2c9481d465 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -855,6 +855,10 @@ pub enum ViewAction { CameraLevel, /// After Enhance page: leave the tool for this command (Crop pages, Organize pages). CameraNext(&'static str), + /// The find bar's Replace (the highlighted match) or Replace all. + Replace { + all: bool, + }, /// Lock now, on the notice shown after a signature is placed (#2149). LockSignatures, } @@ -2809,7 +2813,7 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim }); }); } - find_bar(view, info.pages.len(), avail, ui, t); + find_bar(view, info.pages.len(), avail, tool == QuickTool::EditText && can_modify, ui, t); page_size_hint(view, info, units, avail, unobstructed, ui, t); if tool == QuickTool::EnhanceCamera { crate::camera_ui::bar(ui.ctx(), view, avail, t, can_modify); diff --git a/crates/ui-egui/src/canvas/find.rs b/crates/ui-egui/src/canvas/find.rs index 73b76eb3a6..94e5bf61cf 100644 --- a/crates/ui-egui/src/canvas/find.rs +++ b/crates/ui-egui/src/canvas/find.rs @@ -1,4 +1,4 @@ -use super::{Align2, Color32, CornerRadius, DocView, Range, Rect, Stroke, Tokens, icons, theme, vec2, widgets}; +use super::{Align2, Color32, CornerRadius, DocView, Range, Rect, Stroke, Tokens, ViewAction, icons, theme, vec2, widgets}; /// The find bar (⌘F): query, matches across the document, current match. #[derive(Default)] @@ -26,6 +26,11 @@ pub struct Find { pub whole_words: bool, /// Shown in the Search panel (Advanced Search) instead of the find bar. pub in_panel: bool, + /// Find and replace (in Edit text mode): the Replace row shows, with what replaces a match. + pub replacing: bool, + pub replacement: String, + /// Focus the Replace field on the next frame. + pub focus_replace: bool, } /// What a text layer arriving after a search began may do with the automatic choice. @@ -80,6 +85,19 @@ impl DocView { f.focus = true; } + /// Open the find bar with its Replace row (Edit text mode shows it): the Find field takes the + /// keyboard while it is empty, else the Replace field. + pub fn open_replace(&mut self) { + let f = self.find.get_or_insert_with(Find::default); + f.in_panel = false; + f.replacing = true; + if f.query.trim().is_empty() { + f.focus = true; + } else { + f.focus_replace = true; + } + } + /// Re-run the search on all pages whose text is known (after the query changed). pub fn rerun_find(&mut self) { let Some(f) = self.find.as_mut() else { return }; @@ -249,8 +267,10 @@ impl DocView { } } -/// Acrobat-style floating find bar at the top-right of the document area. -pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: &Tokens) { +/// Acrobat-style floating find bar at the top-right of the document area. In Edit text mode +/// (`editing`) it offers a Replace row: Replace changes the highlighted match, Replace all every +/// match, each inside its own paragraph. +pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, editing: bool, ui: &mut egui::Ui, t: &Tokens) { let Some(find) = view.find.as_mut() else { return }; // The Search panel shows this search. if find.in_panel { @@ -258,6 +278,7 @@ pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut eg } let mut close = false; let mut step: Option = None; + let mut replace: Option = None; let searched = view.texts.len() + view.text_failed.len(); egui::Area::new(egui::Id::unique(("find-bar", view.pane.key))) .order(egui::Order::Middle) @@ -318,6 +339,13 @@ pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut eg if icons::button(ui, "chevron-down", 26.0, false, &tip).clicked() { step = Some(true); } + if editing { + let tip = crate::commands::command_tip(ui.ctx(), tl!("Replace ({key})"), "edit.replace"); + if icons::button(ui, "replace", 26.0, find.replacing, &tip).clicked() { + find.replacing = !find.replacing; + find.focus_replace = find.replacing; + } + } let opts = icons::button(ui, "settings-2", 26.0, find.case_sensitive || find.whole_words, tl!("Find options")); egui::Popup::menu(&opts).show(|ui| { let a = ui.add(widgets::checkbox(&mut find.whole_words, tl!("Whole words only"))).changed(); @@ -331,12 +359,49 @@ pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut eg close = true; } }); + if editing && find.replacing { + ui.horizontal(|ui| { + ui.add(icons::image("replace", 16.0, t.text_muted)); + let edit = egui::TextEdit::singleline(&mut find.replacement) + .id(egui::Id::unique("replace-input")) + .hint_text(tl!("Replace with")) + .desired_width(220.0) + .frame(egui::Frame::NONE); + let r = ui.add(edit).accessible_name(tl!("Replace with")); + if find.focus_replace { + r.request_focus(); + find.focus_replace = false; + } + let searched = !find.case_query.trim().is_empty(); + // Enter in the field replaces the highlighted match, as the button does. + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && find.current.is_some() { + replace = Some(false); + r.request_focus(); + } + if r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + close = true; + } + ui.add_enabled_ui(find.current.is_some(), |ui| { + if crate::widgets::pill_button(ui, tl!("Replace"), false).clicked() { + replace = Some(false); + } + }); + ui.add_enabled_ui(searched, |ui| { + if crate::widgets::pill_button(ui, tl!("Replace all"), true).clicked() { + replace = Some(true); + } + }); + }); + } }); }); if close { view.find = None; return; } + if let Some(all) = replace { + view.pending_action = Some(ViewAction::Replace { all }); + } if find.query != find.case_query { view.rerun_find(); } diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 1ad3e0f5ed..549a877172 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -275,6 +275,16 @@ impl PdfCraftApp { } "view.select_tool" => self.quick_tool = crate::QuickTool::Select, "view.hand_tool" => self.quick_tool = crate::QuickTool::Hand, + // Find and replace changes page text, so it runs in Edit text mode (as Acrobat's + // does): the command turns the mode on, and the find bar shows its Replace row there. + "edit.replace" => { + if let Some(i) = active { + self.quick_tool = crate::QuickTool::EditText; + self.left = crate::LeftPanel::Tool("edit"); + self.reveal_tools(); + self.views[i].open_replace(); + } + } "view.side_panel" => self.toggle_side_panel(), "view.tools_pane" => self.left_open = !self.left_open, "view.focus_page_input" => { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 4b649f9b5b..96fac50129 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -568,6 +568,7 @@ impl PdfCraftApp { self.leave_camera(i); self.execute(command); } + Some(crate::canvas::ViewAction::Replace { all }) => self.replace_found(i, all), None => {} } // That tab comes forward when its value was refused (its editor is open again with the diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5efbba309d..94418de4c8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -147,6 +147,7 @@ const EDIT: &[&str] = &[ SELECT_ALL, SEP, "edit.find", + "edit.replace", "edit.advanced_search", SEP, "edit.column_select", diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 165651e60d..e13c75ac99 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -12,6 +12,7 @@ pub(super) mod measure_ui; pub(super) mod object_ui; pub mod prepare; pub(super) mod redact_ui; +pub(super) mod replace_ui; pub(super) mod sign_ui; pub(super) mod stamps_ui; pub(super) mod tags_ui; diff --git a/crates/ui-egui/src/tools/replace_ui.rs b/crates/ui-egui/src/tools/replace_ui.rs new file mode 100644 index 0000000000..165c69e69a --- /dev/null +++ b/crates/ui-egui/src/tools/replace_ui.rs @@ -0,0 +1,91 @@ +//! Find and replace from the find bar (Edit text mode): the engine plans the change +//! ([`pdfcraft_engine::Document::plan_replace`]) and it is applied as one edit, so Replace all +//! is one undo step; what was left alone is said, never skipped silently. + +use std::collections::BTreeMap; + +use pdfcraft_engine::find_replace::{Replace, Replacement, Which}; + +use crate::PdfCraftApp; + +impl PdfCraftApp { + /// Replace the find bar's highlighted match (`all` false) or every match in the document, in + /// view `i`, with its Replace field's text. + pub(crate) fn replace_found(&mut self, i: usize, all: bool) { + let Some(view) = self.views.get(i) else { return }; + let Some(find) = view.find.as_ref() else { return }; + if find.case_query.trim().is_empty() { + return; + } + let query = Replace { + find: find.case_query.clone(), + with: find.replacement.clone(), + case_sensitive: find.case_sensitive, + whole_words: find.whole_words, + }; + let Some(doc) = self.session.get(view.id) else { return }; + let (pages, which, found) = if all { + // What the find bar holds on each page it has searched, so matches no paragraph + // holds (a form field, a comment) are counted rather than lost. + let mut found: BTreeMap = view.texts.keys().map(|p| (*p, 0)).collect(); + for (p, _) in &find.matches { + let n = found.entry(*p).or_insert(0); + *n = n.saturating_add(1); + } + ((0..doc.info.pages.len()).collect::>(), Which::All, Some(found)) + } else { + // The highlighted match, by where its first and last glyphs are drawn. + let Some((page, range)) = find.current.and_then(|c| find.matches.get(c)).cloned() else { return }; + let centre = |g: usize| { + view.texts.get(&page).and_then(|t| t.glyphs.get(g)).map(|g| [(g.rect[0] + g.rect[2]) / 2.0, (g.rect[1] + g.rect[3]) / 2.0]) + }; + let (Some(start), Some(end)) = (centre(range.start), centre(range.end.saturating_sub(1))) else { return }; + (vec![page], Which::At { page, start, end }, None) + }; + let plan = match doc.plan_replace(&pages, &query, which, found.as_ref()) { + Ok(plan) => plan, + Err(e) => { + self.notify_fmt("{label} failed: {e}", &[("label", tl!("Replace")), ("e", &e.to_string())]); + return; + } + }; + if let Some(edit) = plan.edit.clone() + && !self.apply_edit(edit) + { + return; + } + if let Some(message) = outcome(&plan, all) { + self.notify(message); + } + } +} + +/// What to tell the user: the count and pages after Replace all, what was left and why. Nothing +/// after one match replaced (the page shows it). +fn outcome(plan: &Replacement, all: bool) -> Option { + let skipped = plan.skipped_total(); + if plan.replaced == 0 && skipped == 0 { + return Some(tl!("No matches to replace").to_string()); + } + let why = plan + .skipped + .iter() + .map(|(reason, n)| crate::i18n::fmt(tl!("{why} ({n})"), &[("why", crate::i18n::t(reason.wording())), ("n", &n.to_string())])) + .collect::>() + .join(", "); + if !all { + return (skipped > 0).then(|| crate::i18n::fmt(tl!("Not replaced: {why}"), &[("why", &why)])); + } + let lang = crate::i18n::current(); + let pages = crate::i18n::trn(lang, plan.pages.len() as u64, "{n} page", "{n} pages"); + let mut message = if plan.replaced == 0 { + tl!("Nothing replaced").to_string() + } else { + crate::i18n::fmt(tl!("Replaced {n} on {pages}"), &[("n", &plan.replaced.to_string()), ("pages", &pages)]) + }; + if skipped > 0 { + message.push_str(". "); + message.push_str(&crate::i18n::fmt(tl!("{k} skipped: {why}"), &[("k", &skipped.to_string()), ("why", &why)])); + } + Some(message) +} diff --git a/crates/ui-egui/tests/it/find_replace.rs b/crates/ui-egui/tests/it/find_replace.rs new file mode 100644 index 0000000000..72dc487998 --- /dev/null +++ b/crates/ui-egui/tests/it/find_replace.rs @@ -0,0 +1,127 @@ +//! Find and replace (Edit text mode): ⇧⌘H turns Edit text on and shows the find bar's Replace +//! row, Replace all rewrites every match inside its paragraph as one undo step, and outside Edit +//! text the row is not offered. + +use egui::{Key, Modifiers}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::{PdfCraftApp, QuickTool}; + +/// Two 300×200 pages of Helvetica text: "cat and cat" and "a cat". +fn fixture() -> Vec { + let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 200] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 7 0 R >> >> >>".into(), + stream("BT /F1 14 Tf 20 150 Td (cat and cat) Tj ET"), + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>".into(), + stream("BT /F1 14 Tf 20 150 Td (a cat) Tj ET"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn texts(h: &Harness<'static, PdfCraftApp>) -> Vec { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + (0..2).flat_map(|p| doc.text_blocks(p).into_iter().map(|b| b.text)).collect() +} + +fn has_field(h: &Harness<'static, PdfCraftApp>, placeholder: &str) -> bool { + h.query_all_by(|n| n.placeholder() == Some(placeholder)).next().is_some() +} + +#[test] +fn replace_all_from_the_find_bar_is_one_undo_step() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("cats.pdf", None, fixture()).expect("opens"); + app + }); + h.run_steps(4); + // ⌘F with the Select tool: the find bar, without a Replace row. + h.key_press_modifiers(Modifiers::COMMAND, Key::F); + h.run_steps(3); + assert!(h.state().views[0].find.is_some()); + assert!(has_field(&h, "Find text")); + assert!(!has_field(&h, "Replace with"), "outside Edit text the find bar doesn't replace"); + // ⇧⌘H: Edit text, and the Replace row. + h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::H); + h.run_steps(3); + assert_eq!(h.state().quick_tool, QuickTool::EditText); + assert!(h.state().views[0].find.as_ref().is_some_and(|f| f.replacing)); + assert!(has_field(&h, "Replace with"), "the Replace row shows in Edit text"); + h.query_by(|n| n.placeholder() == Some("Find text")).expect("find field").type_text("cat"); + // Both pages' text loads in the background before every match is listed. + for _ in 0..400 { + h.run_steps(1); + if h.state().views[0].find.as_ref().is_some_and(|f| f.matches.len() == 3) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert_eq!(h.state().views[0].find.as_ref().map(|f| f.matches.len()), Some(3)); + h.query_by(|n| n.placeholder() == Some("Replace with")).expect("replace field").click(); + h.run_steps(2); + h.query_by(|n| n.placeholder() == Some("Replace with")).expect("replace field").type_text("dog"); + h.run_steps(1); + assert_eq!(h.state().views[0].find.as_ref().map(|f| (f.query.as_str(), f.replacement.as_str())), Some(("cat", "dog"))); + // The row may settle over a few frames once the status text changes its width. + h.run_steps(8); + h.get_by_label("Replace all").click(); + h.run_steps(4); + assert_eq!(texts(&h), ["dog and dog", "a dog"], "{:?}", h.state().toast); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Replace all")); + assert_eq!(s.toast.as_ref().map(|t| t.0.as_str()), Some("Replaced 3 on 2 pages")); + // One Undo puts every page back. + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(2); + assert_eq!(texts(&h), ["cat and cat", "a cat"]); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None); +} + +/// Replace changes the highlighted match only: here the second "cat" on the first page. +#[test] +fn replace_changes_the_highlighted_match() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("cats.pdf", None, fixture()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.replace")); + h.run_steps(3); + h.query_by(|n| n.placeholder() == Some("Find text")).expect("find field").type_text("cat"); + for _ in 0..400 { + h.run_steps(1); + if h.state().views[0].find.as_ref().is_some_and(|f| f.matches.len() == 3) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.state_mut().views[0].find.as_mut().unwrap().replacement = "dog".into(); + h.state_mut().views[0].go_to_match(1); + h.run_steps(8); + h.get_by_label("Replace").click(); + h.run_steps(4); + assert_eq!(texts(&h), ["cat and dog", "a cat"]); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Edit text")); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 42126e316e..6bbf3bbcbf 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -43,6 +43,7 @@ mod editing; mod editor_taps; mod export; mod fill_sign; +mod find_replace; mod folders; mod fonts; mod forms; diff --git a/docs/gaps.md b/docs/gaps.md index 1895b55574..8d472eb2d5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -10,6 +10,7 @@ > **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (gap 3: #2517 a partial rewrite keeps a kept row inside the paragraph and leaves the next marked region closed; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (gap 12: reading order is scored, not just word F1) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-15 · **Change:** minor (gap 3: find and replace in Edit text mode, partial; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (gap 14: Vietnamese catalog shipped; Hindi/Indonesian/Korean and Arabic RTL remain) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (gap 6: #1910 Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (gap 6: #1398 stamps read upright on rotated pages; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -114,7 +115,8 @@ update the numbers. - **Missing:** robust in-place editing of text PdfCraft didn't write: subset fonts without the needed glyphs, CJK and right-to-left text, rotated text, lists, superscript/subscript, keeping - structure tags, find and replace in edit mode; vector and object editing; arrange and align. + structure tags, find and replace beyond plain paragraphs (form fields, comments, matches across + paragraphs); vector and object editing; arrange and align. - **Evidence:** `edit.text-edit` is partial (font reused or Helvetica substituted); `edit.rtl-cjk-editing`, `edit.rotated-text-editing`, `edit.keep-tags-on-edit`, `edit.vector-edit`, `edit.edit-object-tool` are planned. Users: @@ -429,6 +431,7 @@ update the numbers. | 2026-10-17 | minor | Gap 6: the redaction audit counts a cover over one word of a long line instead of a clean bill (#2662, fixed by #2698). Estimates unchanged. | | 2026-10-15 | minor | Gap 12: the text oracle scores reading order as well as word F1, which is a bag of words and cannot see order; the first measurement separated 18 improvements from 6 regressions in a change F1 called unchanged on 26 of 29 files. Estimates unchanged. | | 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. | +| 2026-10-15 | minor | Gap 3: find and replace in Edit text mode (the Find box's Replace row, `text_replace`); each match is rewritten inside its own paragraph, and matches in form fields, comments or across paragraphs are left and counted. Estimates unchanged. | | 2026-10-15 | patch | Gap 24: record localization and installers as partial (unusable Chinese, Telugu and Arabic; unsigned Linux packages) | | 2026-10-13 | minor | Gap 6: Organize Pages selects and reveals the current page on open, including after an earlier grid selection elsewhere (#1910). Estimates unchanged. | | 2026-10-12 | minor | Gap 6: rubber stamps, explicit image rectangles and PDF-page stamps read upright on rotated pages (#1398). Estimates unchanged. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index be8fabac1a..e2d32d8b56 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -2068,11 +2068,11 @@ title = "Find and replace in edit mode" area = "C" tier = "P2" milestone = "M7" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["edit.replace"] +tools = ["text_replace"] +evidence = ["crates/engine/src/find_replace/tests.rs::a_replaced_word_keeps_its_paragraphs_font_and_size", "crates/engine/src/find_replace/tests.rs::a_mixed_style_paragraph_keeps_the_other_runs_styles", "crates/engine/src/find_replace/tests.rs::replace_all_across_pages_is_one_undo_step", "crates/engine/src/find_replace/tests.rs::whole_words_and_case_follow_the_find_options", "crates/engine/src/find_replace/tests.rs::a_match_spanning_two_paragraphs_is_skipped_and_counted", "crates/engine/src/find_replace/tests.rs::a_character_the_font_cannot_draw_is_skipped_and_counted", "crates/engine/src/find_replace/tests.rs::a_character_the_font_cannot_draw_is_skipped_in_a_paragraph_of_several_runs", "crates/engine/src/find_replace/tests.rs::a_replacement_outside_the_fonts_encoding_is_reported_and_changes_nothing", "crates/engine/src/find_replace/tests.rs::a_longer_replacement_adds_rows_and_moves_nothing_else", "crates/engine/src/find_replace/tests.rs::a_longer_replacement_grows_one_row_to_the_margin", "crates/engine/src/find_replace/tests.rs::case_insensitive_replace_all_takes_a_match_across_a_hyphenated_row_end", "crates/engine/src/find_replace/tests.rs::invisible_text_over_a_scan_is_skipped", "crates/engine/src/find_replace/tests.rs::the_highlighted_match_is_the_one_replaced", "crates/engine/src/find_replace/tests.rs::matches_the_paragraphs_dont_hold_are_counted_as_not_editable", "crates/engine/src/find_replace/tests.rs::first_replaces_only_the_first_match_in_document_order", "crates/edit/src/tests.rs::replacing_words_apart_in_a_paragraph_keeps_every_run_between_them", "crates/edit/src/tests.rs::style_sources_must_cover_the_text_with_the_paragraphs_runs", "crates/render/src/text/tests.rs::text_search_agrees_with_the_page_search", "crates/ui-egui/tests/it/find_replace.rs::replace_all_from_the_find_bar_is_one_undo_step", "crates/ui-egui/tests/it/find_replace.rs::replace_changes_the_highlighted_match", "crates/automation/tests/it/automation.rs::replacing_text_through_tools"] +notes = "in Edit text mode the floating Find box (⌘F) gains a Replace with row (⇧⌘H / Ctrl+Shift+H, Edit ▸ Replace…, which turns Edit text on; ⌘H is macOS's Hide and Ctrl+H Read mode): Replace changes the highlighted match, Replace all every match in the document as one undo step. Matching is the find bar's own (case-sensitive and whole-words options, whitespace runs as one space). Each match is rewritten inside its own paragraph through Edit text's paragraph rewrite, which keeps the paragraph's fonts, sizes and colours and rewraps it; the replacement takes the run it lands in and every other run keeps its own style. A longer replacement is not shrunk or hyphenated: one row grows to the page's margin and then wraps, several rows keep their width, and new rows go below without moving anything else, so they can overlap what follows and that is not reported. Left alone and counted in the notice (and text_replace's skipped_by): a match spanning two paragraphs, invisible OCR text, a paragraph whose own font can't draw the new text (no silent switch to another font), a paragraph the engine refuses, and matches the find bar sees outside editable paragraphs (form fields, comments, text inside form XObjects). Missing: replacing inside form fields, comments and form XObjects, matches across paragraphs, a word broken by a hyphen at a row's end found by its unhyphenated spelling (the find bar reads it with a space there), regular expressions and Acrobat's other search options (diacritics, bookmarks, comments), offering a substitute font for a skipped paragraph, and right-to-left (Hebrew) paragraphs of several styles (the paragraph rewrite rewrites them in one style). The highlighted match is mapped to its paragraph by position (paragraph box and an estimate along its run), so two overlapping paragraphs may pick the nearer one." [[feature]] id = "edit.rtl-cjk-editing"