diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 7c8556332b..1ca43d20a8 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -68,7 +68,7 @@ use marks::{matrix_number, n}; pub(crate) use place::undrawable; #[cfg(test)] use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_close, page_fonts, stream_bytes, stream_tag, tagged, wrapper_role}; -use place::{check, check_on_top, contents, page_list, place_tagged}; +use place::{check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint}; pub use place::{stamp, undrawable_message}; #[cfg(test)] diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index 2f2c24c862..3056d3f71b 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -346,6 +346,42 @@ pub(crate) fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: Ok(mark_ref) } +/// Draw `ops`, written for the space whose CTM is `ctm`, on top of everything on `page`, tagged +/// `Edited`. A rewritten invisible paragraph goes here when [`revealed_under_paint`]: an OCR +/// layer written before the scan image it lies under would keep the revealed text hidden (#3973). +pub(crate) fn draw_on_top(doc: &mut Document, page: &pdfcraft_model::Page, ctm: [f64; 6], ops: &[pdfcraft_content::Op]) -> Result<(), EditError> { + if ops.is_empty() { + return Ok(()); + } + use pdfcraft_content::Op; + let mut all = vec![Op::new("q", vec![]), Op::new("cm", ctm.iter().map(|v| pdfcraft_content::num(*v)).collect())]; + all.extend_from_slice(ops); + all.push(Op::new("Q", vec![])); + 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")) +} + +/// The operators to draw on top of `page` ([`draw_on_top`]): all of `paragraph` when `over`, +/// after checking the page takes them, so a refusal leaves the document as it was. +pub(crate) fn lift_on_top( + doc: &Document, + page: &pdfcraft_model::Page, + over: bool, + paragraph: &mut Vec, +) -> Result, EditError> { + if !over { + return Ok(Vec::new()); + } + check_on_top(doc, page)?; + Ok(std::mem::take(paragraph)) +} + fn too_deep_on_top() -> EditError { EditError::Invalid("the page's marked content is nested too deeply to add anything on top of it".into()) } diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 615e7699a2..d1cb997c89 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -28,7 +28,8 @@ pub struct TextBlock { /// Byte ranges in `editor_text` and their source line indexes. pub spans: Vec<(std::ops::Range, usize)>, /// Every line is invisible text (rendering mode 3): an OCR layer over a scanned image, whose - /// pixels show the words. A rewrite draws the new text visibly (see [`BlockStyle::cover`]). + /// pixels show the words. A rewrite draws the new text visibly, on top of the page when + /// the scan is drawn after it (see [`BlockStyle::cover`]). pub invisible: bool, } diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 16b04b406f..08e9b2c5d7 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -25,6 +25,26 @@ pub(super) struct TextState { } impl TextState { + /// The state a page's content starts in (ISO 32000-2 §8.4.1, §9.3.1): what is in force on + /// top of the page, after its content wrapped in `q`/`Q`. + pub(crate) fn initial() -> Self { + TextState { + font: None, + char_spacing: 0.0, + word_spacing: 0.0, + scale: 1.0, + leading: 0.0, + rise: 0.0, + fill: Vec::new(), + render_mode: 0, + stroke: Vec::new(), + line_width: None, + ext_gstate: Vec::new(), + dash: (Vec::new(), 0.0), + line_cap: 0, + } + } + pub(super) fn of(ts: &Ts) -> Self { TextState { font: ts.font.as_ref().map(|(n, _)| (String::from_utf8_lossy(n).into_owned(), ts.size)), diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index 513077a027..f03174885a 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -865,9 +865,26 @@ pub(crate) fn rewrite_block_from( char_spacing: o_state.char_spacing, lead, }; - let plan = - BlockPlan { block: &b, lines: &lines, style, emit, after, movement, state: o_state, tm, size, underline: style.underline == Some(true) }; + 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 initial = TextState::initial(); + let drawn_from = if over { &initial } else { after }; + let plan = BlockPlan { + block: &b, + lines: &lines, + style, + emit, + after: drawn_from, + movement, + state: o_state, + tm, + size, + underline: style.underline == Some(true), + }; let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; + let on_top = crate::lift_on_top(doc, &p, over, &mut block_ops)?; let n = pdfcraft_content::num; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { @@ -875,7 +892,6 @@ pub(crate) fn rewrite_block_from( block_ops.push(Op::new("BT", vec![])); block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; (insert, !dropped.contains(&i)) @@ -892,12 +908,14 @@ pub(crate) fn rewrite_block_from( d.set(b"Resources".to_vec(), Object::Dict(res)); } })?; + crate::draw_on_top(doc, &p, emit.ctm, &on_top)?; Ok(LineEdit { substituted: font.substituted }) } /// The cover (when [`BlockStyle::cover`] asks for one) and visible rendering that start a /// 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. +/// 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`]). pub(crate) fn reveal_ops(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, ctm: [f64; 6]) -> Result, EditError> { if !block.invisible { return Ok(Vec::new()); diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index e8b3b683d0..c03b75d3f4 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1,3 +1,4 @@ +use super::super::interp::TextState; use super::grouping::{across, is_centred, row_rect}; use super::{ BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, @@ -783,6 +784,12 @@ pub(crate) fn rewrite_styled_block( let o = &lead.origin; // What is in force where the rewrite is drawn, and what the text after it expects. let after = if split { &o.state } else { &o.bt_state }; + 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 initial = TextState::initial(); + let drawn_from = if over { &initial } else { after }; validate_contextual_rows(&rows, &kept)?; let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; @@ -869,7 +876,7 @@ pub(crate) fn rewrite_styled_block( // object (`gs`, stroke, line width, `Tr`; #3508/#3509) from the state the rewrite is // drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next. block_ops.push(Op::new("q", vec![])); - block_ops.extend(state.paint_ops(after)); + block_ops.extend(state.paint_ops(drawn_from)); if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) { let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?; let mut properties = Dict::new(); @@ -967,13 +974,13 @@ pub(crate) fn rewrite_styled_block( }; } block_ops.push(Op::new("Q", vec![])); + let on_top = crate::lift_on_top(doc, &p, over, &mut block_ops)?; block_ops.extend(after.ops()); if split { block_ops.insert(0, Op::new("ET", vec![])); block_ops.push(Op::new("BT", vec![])); block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { o.bt_op }; let data = splice(&pieces, &ops, |i| (if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }, !drop.contains(&i))); let contents = rewritten_contents(doc, &streams, data); res.set(b"Font".to_vec(), Object::Dict(fonts)); @@ -981,5 +988,6 @@ pub(crate) fn rewrite_styled_block( d.set(b"Contents".to_vec(), contents); d.set(b"Resources".to_vec(), Object::Dict(res)); })?; + crate::draw_on_top(doc, &p, o.ctm, &on_top)?; Ok(LineEdit { substituted }) } diff --git a/crates/engine/tests/it/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs index 8009ef1bbf..86fc1c9df2 100644 --- a/crates/engine/tests/it/ocr_edit.rs +++ b/crates/engine/tests/it/ocr_edit.rs @@ -207,3 +207,84 @@ fn visible_stretched_text_rewrites_unchanged() { let i = ((y * out.width + x) * 4) as usize; assert_eq!(&out.rgba[i..i + 3], &[255, 255, 255]); } + +/// A scan whose OCR layer is drawn first and the opaque page image over it, as some OCR tools +/// write it (#3973): invisible Helvetica `text` on the scanned word, then the image. +fn ocr_under_scan(text: &str) -> std::sync::Arc> { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; + let (w, h) = (200u32, 100u32); + let mut rgb = Vec::new(); + for y in 0..h { + for x in 0..w { + let ink = (WORD[0]..WORD[2]).contains(&x) && (WORD[1]..WORD[3]).contains(&y); + rgb.extend(if ink { INK } else { PAPER }); + } + } + let mut d = Document::new_empty(); + let pages = d.add(Object::Dict(Dict::new())); + let content = format!("BT /F1 20 Tf 3 Tr 42 44 Td ({text}) Tj ET q 200 0 0 100 0 0 cm /Im Do Q"); + let contents = d.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + let mut image = Dict::new(); + image.set(b"Type".to_vec(), Object::name("XObject")); + image.set(b"Subtype".to_vec(), Object::name("Image")); + image.set(b"Width".to_vec(), Object::Int(i64::from(w))); + image.set(b"Height".to_vec(), Object::Int(i64::from(h))); + image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + image.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + let image = d.add(Object::Stream(Stream::flate(image, &rgb))); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Dict(font)); + let mut xobjects = Dict::new(); + xobjects.set(b"Im".to_vec(), Object::Ref(image)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"Contents".to_vec(), Object::Ref(contents)); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec())); + let page = d.add(Object::Dict(p)); + d.update_dict(pages, |p| { + p.set(b"Type".to_vec(), Object::name("Pages")); + p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + p.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + let mut root = Dict::new(); + root.set(b"Type".to_vec(), Object::name("Catalog")); + root.set(b"Pages".to_vec(), Object::Ref(pages)); + let root = d.add(Object::Dict(root)); + d.trailer_mut().set(b"Root".to_vec(), Object::Ref(root)); + std::sync::Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap()) +} + +/// The OCR layer is under the scan image: the rewritten paragraph is drawn on top of the image, +/// not in the OCR text's place where the image would hide it (#3973). One line takes the +/// single-style rewrite, a typed line break the styled one. +#[test] +fn an_ocr_layer_under_the_scan_image_shows_its_edit() { + for edit in ["Jane", "Jane\nDoe"] { + let mut s = Session::new(); + let id = s.open("ocr-under-scan.pdf", None, ocr_under_scan("John"), None).unwrap(); + let blocks = s.get(id).unwrap().text_blocks(0); + assert_eq!(blocks.len(), 1); + assert!(blocks[0].invisible, "the OCR layer is invisible text"); + assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "the scanned word fills its box"); + + s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: edit.into(), style: Default::default() }).unwrap(); + + let blocks = s.get(id).unwrap().text_blocks(0); + assert_eq!(blocks.iter().map(|b| b.text.replace('\n', " ")).collect::>(), [edit.replace('\n', " ")], "{edit:?}"); + let px = word_pixels(&s, id); + let paper = px.iter().filter(|p| near(**p, PAPER)).count(); + let ink = px.iter().filter(|p| near(**p, INK)).count(); + assert!(paper * 10 > px.len() * 4, "{edit:?}: the cover is drawn over the scan: {paper} of {} paper", px.len()); + assert!(ink * 100 > px.len() * 3, "{edit:?}: the new text shows: {ink} of {} ink", px.len()); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 3b8dba920c..aaaf789f39 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6628,8 +6628,8 @@ milestone = "M10" 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/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; 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), and covering on a textured or patterned background (one flat colour is used)." +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/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; the new text is drawn on top of the page, so it shows also when the OCR layer was written under the scan image (#3973); 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), and covering on a textured or patterned background (one flat colour is used)." [[feature]] id = "ocr.ocr-downsample"