mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 18:52:36 +00:00
fix(edit): an edited OCR phrase under its scan image is drawn on top (#3973)
Some OCR tools write the invisible text layer first and the opaque page image after it. Edit text rewrote such a phrase in the text's place, so the cover and the new visible text were painted under the scan: the edit was saved (text extraction finds it) but nothing showed. When the rewritten paragraph was invisible and an XObject, inline image or shading is painted after its place, the cover and the new text now go on top of the page in their own q/Q (place_tagged, tagged Edited), drawn from the initial graphics state with the paragraph's CTM. Only the state the following text expects stays in place. Both rewrite paths (single style and styled rows) do this. An OCR layer drawn over its scan, the common case, is rewritten in place as before, so reading order is kept. The test builds a scan whose OCR word comes before the image and checks the cover and new text show, for one line and for a typed line break. 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
f76dbb9e8c
commit
cc9d95e1fb
8 files changed
+174
-10
No files matched your search
@@ -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)]
|
||||
|
||||
@@ -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<pdfcraft_content::Op>,
|
||||
) -> Result<Vec<pdfcraft_content::Op>, 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())
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ pub struct TextBlock {
|
||||
/// Byte ranges in `editor_text` and their source line indexes.
|
||||
pub spans: Vec<(std::ops::Range<usize>, 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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<Vec<Op>, EditError> {
|
||||
if !block.invisible {
|
||||
return Ok(Vec::new());
|
||||
|
||||
@@ -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::<Result<_, _>>()?;
|
||||
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 })
|
||||
}
|
||||
@@ -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<Vec<u8>> {
|
||||
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::<Vec<_>>(), [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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user