mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 18:52:36 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
1 parent
5a2f9273a3
commit
3c11186dbd
6 files changed
+37
-20
No files matched your search
@@ -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<bool, EditError> {
|
||||
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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
+17
-12
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user