mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
fix(edit): review follow-ups to keeping OCR words (refs #3313)
- A draft with the same words (a space typed after the last) changes nothing and is no undo step. It used to redraw the whole phrase. - Rewriting words alone is refused when it would move any other text on the page, visible text too, not only other OCR words. - A cover stops at the words either side that stay: the padding of a large word could reach over the ink of a small one beside it. - Spacing other than one space between words goes to the whole-phrase rewrite, which keeps it, instead of being dropped. - A squeeze is relative to the scale the words already have. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4731036cdb
commit
a6229de16d
5 files changed
+128
-18
No files matched your search
@@ -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)]
|
||||
|
||||
@@ -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<Vec<Op>, 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()) {
|
||||
|
||||
@@ -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<Option<f64>, 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<usize>| 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<Vec<&str>> = 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)?;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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::<String>::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<Vec<u8>> {
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};
|
||||
|
||||
Reference in new issue
Block a user