diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 1ef7b70f47..e3fbf60622 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -44,6 +44,12 @@ impl TextBlock { (self.invisible && self.size.is_finite() && self.size > 0.0).then_some(self.size) } + /// Whether `draft` says what the paragraph says, give or take space around it. Spacing inside + /// it has to match: a typed second space is a change. + pub fn same_words(&self, draft: &str) -> bool { + draft.trim() == self.editor_text.trim() + } + /// Preserve styles outside the changed ranges; inserted characters inherit the surrounding /// source run. The texts are aligned word by word, so words left untouched between two edits /// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change. @@ -456,6 +462,9 @@ pub struct BlockStyle { /// 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`. pub changed_words_only: bool, + /// How far across [`BlockStyle::cover`] may reach, whatever its padding: the edges of the words + /// on either side that stay (user space). + pub cover_between: Option<[f64; 2]>, } #[derive(Clone, Debug, PartialEq)] diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 927a05119a..355134b4c0 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -999,6 +999,14 @@ pub(crate) fn rewrite_block_from( /// rewritten invisible paragraph, drawn in the space whose CTM is `ctm`. Nothing for visible text. /// The paragraph's `q`/`Q` restores the rendering mode for the invisible text after it; one /// painted over where it was is drawn on top of the page instead ([`crate::draw_on_top`]). +/// What covering the word in `rect` paints: scanned ink reaches a little past the recognised box, +/// but not past `between`, the words on either side that stay. It always covers the word itself. +pub(crate) fn cover_box(rect: [f64; 4], between: Option<[f64; 2]>) -> [f64; 4] { + let pad = ((rect[3] - rect[1]).abs() * 0.1).clamp(0.5, 2.0); + let [left, right] = between.unwrap_or([f64::NEG_INFINITY, f64::INFINITY]); + [(rect[0] - pad).max(left.min(rect[0])), rect[1] - pad, (rect[2] + pad).min(right.max(rect[2])), rect[3] + pad] +} + pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, ctm: [f64; 6]) -> Result, EditError> { if !block.invisible { return Ok(Vec::new()); @@ -1012,11 +1020,8 @@ pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockSty let back = Matrix(ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be covered in".into()))?; ops.push(Op::new("q", vec![])); ops.push(Op::new("rg", colour.map(n).to_vec())); - for rect in block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect) { - // Scanned ink reaches a little past the recognised box. - let pad = ((rect[3] - rect[1]).abs() * 0.1).clamp(0.5, 2.0); - let corners = - [[rect[0] - pad, rect[1] - pad], [rect[2] + pad, rect[1] - pad], [rect[2] + pad, rect[3] + pad], [rect[0] - pad, rect[3] + pad]]; + for rect in block.lines.iter().filter_map(|i| lines.get(*i)).map(|l| cover_box(l.rect, style.cover_between)) { + let corners = [[rect[0], rect[1]], [rect[2], rect[1]], [rect[2], rect[3]], [rect[0], rect[3]]]; for (i, [x, y]) in corners.into_iter().enumerate() { let (x, y) = back.apply(x, y); if !(x.is_finite() && y.is_finite()) { diff --git a/crates/edit/src/text_regions/ocr_words.rs b/crates/edit/src/text_regions/ocr_words.rs index fac6c67850..c62ef85bf3 100644 --- a/crates/edit/src/text_regions/ocr_words.rs +++ b/crates/edit/src/text_regions/ocr_words.rs @@ -104,28 +104,36 @@ fn key(line: &TextLine) -> (&str, [i64; 4]) { } /// How far right a rewrite of the runs `indices` drew (`None` when it drew nothing), or an error -/// when it moved or lost any other invisible word: one positioned after the end of the text before -/// it, say, depends on the words that went. +/// when it moved or lost any other text on the page: text positioned after the end of the text +/// before it, say, depends on the words that went. fn drawn_right(before: &[TextLine], indices: &[usize], after: &[TextLine]) -> Result, EditError> { let mut unmatched: HashMap<_, usize> = HashMap::new(); for line in before { let count = unmatched.entry(key(line)).or_default(); *count = count.saturating_add(1); } - let mut right = None; + let mut drawn = Vec::new(); for line in after { match unmatched.get_mut(&key(line)).filter(|count| **count > 0) { Some(count) => *count -= 1, - None => right = Some(right.map_or(line.rect[2], |r: f64| r.max(line.rect[2]))), + None => drawn.push(line), } } - let moved = before.iter().enumerate().any(|(index, line)| { - line.origin.state.render_mode == 3 && !indices.contains(&index) && unmatched.get(&key(line)).is_some_and(|count| *count > 0) - }); + // Visible text beside what was drawn reads as one line with it. It is still where it was. + let joined = |line: &TextLine| { + let [x0, y0, x1, y1] = line.rect; + drawn.iter().any(|d| { + d.text.contains(line.text.trim()) && d.rect[0] <= x0 + 0.01 && d.rect[1] <= y0 + 0.01 && d.rect[2] >= x1 - 0.01 && d.rect[3] >= y1 - 0.01 + }) + }; + let moved = before + .iter() + .enumerate() + .any(|(index, line)| !indices.contains(&index) && unmatched.get(&key(line)).is_some_and(|c| *c > 0) && !joined(line)); if moved { - return Err(EditError::Invalid("rewriting these words alone would move the words around them".into())); + return Err(EditError::Invalid("rewriting these words alone would move the text around them".into())); } - Ok(right) + Ok(drawn.iter().map(|line| line.rect[2]).reduce(f64::max)) } /// Rewrite each change of `plan` in `doc`, last first: a rewrite goes where its words were or at @@ -148,7 +156,15 @@ fn apply( let indices = members.get(change.runs.clone()).ok_or_else(|| EditError::Invalid("the phrase has no such words".into()))?; let before = word_lines(doc, page)?; let part = stretch(phrase, &before, indices)?; - style.scale = change.squeeze.map(|squeeze| squeeze * 100.0); + // Relative to the scale the words have, which a layer may have narrowed already. + let own_scale = indices.first().and_then(|i| before.get(*i)).map_or(1.0, |line| line.origin.state.scale); + style.scale = change.squeeze.map(|squeeze| squeeze * own_scale * 100.0); + // The words on either side stay, with all their ink. + let edge = |run: Option| run.and_then(|run| members.get(run)).and_then(|i| before.get(*i)).map(|line| line.rect); + style.cover_between = Some([ + edge(change.runs.start.checked_sub(1)).map_or(f64::NEG_INFINITY, |rect| rect[2]), + edge(Some(change.runs.end)).map_or(f64::INFINITY, |rect| rect[0]), + ]); let edit = rewrite_styled_block(doc, page, &part, &before, Some(&change.text), &style, max_glyphs)?; substituted = substituted.or(edit.substituted); let right = drawn_right(&before, indices, &word_lines(doc, page)?)?; @@ -219,9 +235,13 @@ pub(crate) fn rewrite_changed_words( let runs: Vec> = members.iter().filter_map(|i| lines.get(*i)).map(|line| line.text.split_whitespace().collect()).collect(); let old: Vec<&str> = runs.iter().flatten().copied().collect(); let new: Vec<&str> = draft.split_whitespace().collect(); - // Words the layer doesn't show apart (a run per letter, say) can't be kept apart. With one - // word, or none changed, there is nothing to keep or nothing to do. - if old.len() < 2 || old == new || !phrase.editor_text.split_whitespace().eq(old.iter().copied()) { + // Words the layer doesn't show apart (a run per letter, say) can't be kept apart, and spacing + // other than one space between words is the draft's to keep, which only the whole rewrite can. + if !phrase.editor_text.split_whitespace().eq(old.iter().copied()) || draft.trim() != new.join(" ") { + return None; + } + // A single word that changes has nothing to keep. With the same words, the plan is empty. + if old.len() < 2 && old != new { return None; } let kept_as = kept_words(&old, &new)?; diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index dacb55ddae..e0edd56035 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -70,6 +70,10 @@ impl Session { style.color = style.color.or(Some(ink)); // A text edit alone leaves the words it doesn't change as the scan shows them. style.changed_words_only = plain; + // Nothing to redraw: leave undo history and the dirty flag alone. + if plain && b.same_words(&text) { + return Ok(None); + } } Edit::EditTextBlock { page, block, text, style } } diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 7004817b2f..e425a9722d 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -446,6 +446,78 @@ fn a_kept_ocr_word_can_be_edited_after_a_save() { assert_eq!(scan_and_drawn(&s, id), (vec!["Example".to_owned()], vec!["Jon".to_owned(), "Smyth".to_owned()])); } +/// A draft with the same words, a space typed after the last of them, changes nothing: not the +/// page, and not the undo history. It used to redraw the whole line. +#[test] +fn a_draft_with_the_same_words_changes_nothing() { + let (mut s, id) = uneven_phrase(); + let (bytes, generation) = (s.get(id).unwrap().bytes.clone(), s.get(id).unwrap().edit_generation()); + for draft in ["John Smith Example ", " John Smith Example"] { + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: draft.into(), style: Default::default() }).unwrap(); + assert_eq!(s.get(id).unwrap().bytes, bytes, "{draft:?} wrote nothing"); + assert_eq!(s.get(id).unwrap().edit_generation(), generation, "{draft:?} is no undo step"); + assert_eq!(scan_and_drawn(&s, id).1, Vec::::new(), "{draft:?} drew nothing"); + } +} + +/// Visible text positioned after the end of an OCR word depends on that word. An edit may not +/// leave the word gone and the visible text somewhere else while calling the rest kept. +#[test] +fn visible_text_that_follows_an_ocr_word_stops_it_being_kept_alone() { + let bytes = under_scan("BT /F1 10 Tf 3 Tr 20 44 Td (John) Tj ( ) Tj (Smith) Tj 0 Tr /F1 14 Tf ( Label) Tj ET"); + let mut s = Session::new(); + let id = s.open("label.pdf", None, bytes, None).unwrap(); + let label = |s: &Session| s.get(id).unwrap().text_lines(0).iter().find(|l| l.text.contains("Label")).map(|l| l.rect[0]); + assert_eq!(scan_and_drawn(&s, id).0, ["John Smith"]); + let before = label(&s).unwrap(); + edit_block(&mut s, id, 0, "John"); + let (scan, _) = scan_and_drawn(&s, id); + assert!(scan.is_empty(), "John can't stay alone when Label would move with it: {scan:?}, Label at {:?} from {before}", label(&s)); +} + +/// A large word right before a small one, as OCR's sizes can make them: the large word's cover, +/// padded in proportion to its height, stops at the small word, or that word would lose ink while +/// staying in the text. +#[test] +fn a_cover_stops_at_the_word_beside_it() { + let (mut s, id) = scanned_with(vec![scanned_word("John", 42.0, 66.0, 42.0, 30.0), scanned_word("Smith", 67.2, 98.0, 42.0, 8.0)]); + assert_eq!(s.get(id).unwrap().text_blocks(0)[0].text, "John Smith"); + assert!(paper_between(&s, id, 67, 70) < 0.02, "Smith's start is ink to begin with"); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "Smith".into(), style: Default::default() }).unwrap(); + assert_eq!(scan_and_drawn(&s, id), (vec!["Smith".to_owned()], Vec::new()), "only John goes"); + assert!(paper_between(&s, id, 44, 64) > 0.98, "John is covered"); + // John's padding would reach to x = 68, over this column of Smith's ink. + let smith = paper_between(&s, id, 67, 68); + assert!(smith < 0.02, "Smith keeps its ink: {smith}"); +} + +/// Spacing the word plan can't hold goes to the rewrite that can: it isn't quietly dropped. +#[test] +fn spacing_inside_a_draft_is_kept_by_redrawing_the_phrase() { + let (mut s, id) = uneven_phrase(); + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smyth Example".into(), style: Default::default() }).unwrap(); + let (scan, drawn) = scan_and_drawn(&s, id); + assert!(scan.is_empty(), "{scan:?}"); + assert!(drawn.join(" ").contains("John Smyth"), "the typed spaces are there: {drawn:?}"); +} + +/// A layer whose words are already narrowed (50 Tz): squeezing a replacement narrows it further, +/// rather than setting a scale wider than the one it had and running it into the next word. +#[test] +fn a_squeeze_is_relative_to_the_words_own_scale() { + let bytes = under_scan("BT /F1 12 Tf 3 Tr 50 Tz 1 0 0 1 20 44 Tm (John) Tj 1 0 0 1 36 44 Tm (Smith) Tj 1 0 0 1 56 44 Tm (Jones) Tj ET"); + let mut s = Session::new(); + let id = s.open("narrow.pdf", None, bytes, None).unwrap(); + assert_eq!(scan_and_drawn(&s, id).0, ["John Smith Jones"]); + edit_block(&mut s, id, 0, "John Smithy Jones"); + let (scan, drawn) = scan_and_drawn(&s, id); + assert_eq!(drawn, ["Smithy"], "Jones isn't taken over; the scan has {scan:?}"); + let lines = s.get(id).unwrap().text_lines(0); + let smithy = lines.iter().find(|l| l.text == "Smithy").unwrap(); + let jones = lines.iter().find(|l| l.text == "Jones").unwrap(); + assert!(smithy.rect[2] <= jones.rect[0], "{:?} runs into {:?}", smithy.rect, jones.rect); +} + /// Visible text set with a horizontally stretched text matrix (3 × 1), as some generators write. fn stretched_visible() -> std::sync::Arc> { use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};