From 3c11186dbd9d919aefedb3f74f6f808570dd353a Mon Sep 17 00:00:00 2001 From: KenM76 Date: Fri, 30 Oct 2026 01:22:41 -0400 Subject: [PATCH] fix(edit): move revealed OCR text on top only when an XObject covers it An unrelated image later on the page no longer moves the paragraph and its cover on top, where the cover could hide visible content inside the paragraph's box. An XObject now counts only when it is drawn at or after the paragraph and its visible box overlaps the paragraph's (`images::painted_over`, from `page_images`). An inline image or a shading after the paragraph still counts anywhere, and so does any XObject when the page's images cannot be listed. The trigger table now covers an image elsewhere, an image drawn first, visible text, a filled path, a shading and an inline image. It fails with the old any-`Do` trigger. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9 --- crates/edit/src/images.rs | 6 ++++++ crates/edit/src/place.rs | 16 +++++++++----- crates/edit/src/tests.rs | 29 +++++++++++++++----------- crates/edit/src/text/rewrite.rs | 2 +- crates/edit/src/text_regions/styled.rs | 2 +- parity/acrobat-features.toml | 2 +- 6 files changed, 37 insertions(+), 20 deletions(-) diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 5180e76018..7322b8f9d6 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -536,6 +536,12 @@ pub(crate) fn selectable_images( .collect()) } +/// Whether an XObject drawn at or after operator `from` of the page's joined operators shows +/// inside `rect` (user space). +pub(crate) fn painted_over(doc: &Document, page: usize, rect: [f64; 4], from: usize) -> Result { + Ok(page_images(doc, page)?.iter().any(|i| i.op >= from && i.visible_rect.and_then(|v| intersection(v, rect)).is_some())) +} + /// Find the saved graphics-state scope containing just this image and its crop. Applying /// the transform before that scope moves the actual clipping path as well as the pixels. /// A shared clip must never be moved along with unrelated artwork or text. diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index f1acb6bdca..e040acf1f3 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -360,11 +360,17 @@ pub(crate) fn draw_on_top(doc: &mut Document, page: &pdfcraft_model::Page, ctm: place_tagged(doc, page, "Edited", pdfcraft_content::serialize_ops(&all), false).map(drop) } -/// Whether a rewritten paragraph that was `invisible` goes on top of the page: anything after -/// operator `at` can paint over it there (an XObject, an inline image or a shading), as the scan -/// image does over an OCR layer written before it (#3973). -pub(crate) fn revealed_under_paint(invisible: bool, ops: &[pdfcraft_content::Op], at: usize) -> bool { - invisible && ops.get(at..).unwrap_or_default().iter().any(|op| op.is("Do") || op.is("BI") || op.is("sh")) +/// Whether a rewritten paragraph that was `invisible` goes on top of page `page`: something after +/// operator `at` of the page's joined `ops` paints over its `rect` (user space), as the scan image +/// does over an OCR layer written before it (#3973). XObjects count where they are drawn; an inline +/// image or a shading after it counts anywhere, and so does any XObject when the page's images +/// cannot be listed. +pub(crate) fn revealed_under_paint(doc: &Document, page: usize, invisible: bool, rect: [f64; 4], ops: &[pdfcraft_content::Op], at: usize) -> bool { + let later = ops.get(at..).unwrap_or_default(); + if !invisible || later.iter().any(|op| op.is("BI") || op.is("sh")) { + return invisible; + } + later.iter().any(|op| op.is("Do")) && crate::images::painted_over(doc, page, rect, at).unwrap_or(true) } /// The operators to draw on top of the page ([`draw_on_top`]): all of `paragraph` when `over`, diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index a51612106b..4349ce7ad6 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -4550,20 +4550,25 @@ fn group_selection_refuses_incomplete_content_and_resource_budget_overruns() { assert!(editable_objects(&doc, 0).is_err(), "an undecodable stream is refused"); } -/// Only invisible text with an XObject, inline image or shading painted after its place moves on top. +/// Invisible text moves on top only when something painted after it covers it: an XObject drawn +/// over its box, or any inline image or shading. #[test] fn revealed_text_moves_on_top_only_when_painted_over() { - let ops = pdfcraft_content::parse(b"BT (a) Tj ET q /Im Do Q BT (b) Tj ET BI /W 1 ID x EI /Sh sh").ops; - let at = |name: &str, n: usize| ops.iter().enumerate().filter(|(_, o)| o.is(name)).nth(n).map(|(i, _)| i).unwrap(); - let after_bi = at("BI", 0) + 1; - assert!(revealed_under_paint(true, &ops, at("BT", 0))); - assert!(revealed_under_paint(true, &ops, at("BT", 1)), "inline image and shading after it"); - assert!(revealed_under_paint(true, &ops, after_bi), "a shading after it"); - assert!(!revealed_under_paint(true, &ops, ops.len()), "nothing after it"); - assert!(!revealed_under_paint(true, &ops, ops.len() + 5), "past the end"); - assert!(!revealed_under_paint(false, &ops, 0), "visible text stays where it is"); - let text_only = pdfcraft_content::parse(b"BT (a) Tj ET 0 0 10 10 re f").ops; - assert!(!revealed_under_paint(true, &text_only, 0), "a filled path does not move it"); + let on_top = |content: &str| { + let doc = image_page_with(content); + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let at = ops.iter().position(|o| o.is("BT")).unwrap(); + revealed_under_paint(&doc, 0, block.invisible, block.rect, &ops, at) + }; + let scan = "q 200 0 0 100 100 100 cm /Im0 Do Q"; + assert!(on_top(&format!("BT /F1 12 Tf 3 Tr 110 150 Td (Under) Tj ET {scan}")), "the scan covers it"); + assert!(!on_top(&format!("BT /F1 12 Tf 3 Tr 400 600 Td (Away) Tj ET {scan}")), "an image elsewhere"); + assert!(!on_top(&format!("{scan} BT /F1 12 Tf 3 Tr 110 150 Td (Over) Tj ET")), "the scan is drawn first"); + assert!(!on_top(&format!("BT /F1 12 Tf 0 Tr 110 150 Td (Shown) Tj ET {scan}")), "visible text stays where it is"); + assert!(!on_top("BT /F1 12 Tf 3 Tr 110 150 Td (Path) Tj ET 0 0 600 800 re f"), "a filled path does not move it"); + assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Shade) Tj ET /Sh sh"), "a shading after it"); + assert!(on_top("BT /F1 12 Tf 3 Tr 400 600 Td (Inline) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 ID x EI"), "an inline image after it"); } /// Revealed OCR text may have to go on top of the page; a page whose marked content nests too deep diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 42db5c4af3..768693e70a 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -873,7 +873,7 @@ pub(crate) fn rewrite_block_from( let at = if split { start } else { emit.bt_op }; // Revealed OCR text that something is painted over goes on top of the page, where it starts // from the initial state. - let over = crate::revealed_under_paint(b.invisible, &ops, at); + let over = crate::revealed_under_paint(doc, page, b.invisible, b.rect, &ops, at); let initial = TextState::initial(); let drawn_from = if over { &initial } else { after }; let plan = BlockPlan { diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 8ff4b0b62e..6ea69b4dc8 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -787,7 +787,7 @@ pub(crate) fn rewrite_styled_block( let at = if split { start } else { o.bt_op }; // Revealed OCR text that something is painted over goes on top of the page, where it starts // from the initial state. - let over = crate::revealed_under_paint(block.invisible, &ops, at); + let over = crate::revealed_under_paint(doc, page, block.invisible, block.rect, &ops, at); let initial = TextState::initial(); let drawn_from = if over { &initial } else { after }; validate_contextual_rows(&rows, &kept)?; diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 68716cf5fd..5b5927d91d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6629,7 +6629,7 @@ status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image, inline image or shading is painted after the OCR layer (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn over the OCR layer's box after it, or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer." [[feature]] id = "ocr.ocr-downsample"