mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 04:58:34 -06:00
M10: Edit reads only a page with no text of its own
Review of #1609: the Edit-triggered recognition also read pages that have real text (a flyer or slide export with a full-page background picture), adding an undo step and marking the file modified just because Edit was turned on. It now runs with skip_text_pages on, so such a page is left alone. A new OcrSettings::native_text_only decides what counts as the page's text: its own content and form fields, not comments and text boxes (which are also not drawn into the picture that is read), so a scan with a few comments on it is still a scan, as in Acrobat. The doc comment now says what the code does. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
e46c7644d9
commit
2f761c852d
+1
-1
@@ -148,7 +148,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-12 (M10, Edit on a scanned page):** Turning Edit text & images on for a scanned page (an image covering at least half of a page with no text) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with text, read-only files and other tools are left alone. The photographs on a scan are still part of the one page image.
|
||||
- **2026-10-12 (M10, Edit on a scanned page):** Turning Edit on (its tab, Edit text & images or Add text) for a scanned page (an image covering at least half of a page with no text of its own; comments and text boxes over it do not count) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with real text (a flyer with a background picture), read-only files and other tools are left alone. The photographs on a scan are still part of the one page image.
|
||||
- **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged.
|
||||
- **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged.
|
||||
- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged.
|
||||
|
||||
@@ -24,6 +24,11 @@ pub struct OcrSettings {
|
||||
/// text" and skips them). When `false`, such pages are read too (a picture on a page that
|
||||
/// also has text, #1028) and only the words that aren't already there are added.
|
||||
pub skip_text_pages: bool,
|
||||
/// What counts as the page's text when `skip_text_pages` is on: only its own (page content
|
||||
/// and form fields). Comments and text boxes are left out, and are not drawn into the picture
|
||||
/// that is read, so a scan with a few comments on it is still a scan. `false`: everything
|
||||
/// the page shows.
|
||||
pub native_text_only: bool,
|
||||
}
|
||||
|
||||
/// A recognised word whose box is at least this much covered by the page's own text is that
|
||||
@@ -47,7 +52,7 @@ fn covered(word: [f32; 4], text: &[[f32; 4]]) -> f32 {
|
||||
|
||||
impl Default for OcrSettings {
|
||||
fn default() -> Self {
|
||||
OcrSettings { dpi: 300.0, language: "en".into(), skip_text_pages: true }
|
||||
OcrSettings { dpi: 300.0, language: "en".into(), skip_text_pages: true, native_text_only: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +106,8 @@ impl OcrJob {
|
||||
/// Read the pages. `progress(done, total)` is called before each page; returning `false`
|
||||
/// stops (the pages read so far are returned).
|
||||
pub fn run(self, ocr: &Ocr, mut progress: impl FnMut(usize, usize) -> bool) -> Vec<OcrPage> {
|
||||
let config = RenderConfig { password: self.password.as_deref().map(Arc::from), ..Default::default() };
|
||||
let config =
|
||||
RenderConfig { password: self.password.as_deref().map(Arc::from), hide_comments: self.settings.native_text_only, ..Default::default() };
|
||||
let mut r = PageRenderer::new(self.bytes.clone(), config);
|
||||
let mut out = Vec::new();
|
||||
let total = self.pages.len();
|
||||
|
||||
@@ -2670,6 +2670,62 @@ fn recognize_text_reads_a_picture_on_a_page_that_has_text() {
|
||||
assert_eq!((count(&s, "invoice"), count(&s, "brown")), (1, 1));
|
||||
}
|
||||
|
||||
/// Edit reads a scan that only has comments on it, not a page with real text and a picture
|
||||
/// (`native_text_only`): a text box over the picture is not the page's text, page content is.
|
||||
#[test]
|
||||
fn native_text_only_ignores_comments_but_not_page_text() {
|
||||
if !ocr::available() {
|
||||
eprintln!("skipped: OCR models not installed");
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap();
|
||||
let id = s.open("text.pdf", None, text, None).unwrap();
|
||||
let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap();
|
||||
let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap();
|
||||
let comment_only = |s: &mut Session| {
|
||||
let id = s.open("scan.pdf", None, scan.clone(), None).unwrap();
|
||||
let (w, h) = {
|
||||
let p = &s.get(id).unwrap().info.pages[0];
|
||||
(p.width as f64, p.height as f64)
|
||||
};
|
||||
let shape = pdfcraft_annot::Shape::TextBox { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], font_size: 18.0 };
|
||||
s.apply(
|
||||
id,
|
||||
Edit::AddAnnotation(NewAnnotation {
|
||||
page: 0,
|
||||
style: pdfcraft_annot::Style::default_for(&shape),
|
||||
shape,
|
||||
contents: "Invoice reference 4711".into(),
|
||||
author: "Test".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
id
|
||||
};
|
||||
let native = ocr::OcrSettings { native_text_only: true, ..Default::default() };
|
||||
|
||||
// A comment over the picture: the default run counts it as text and skips the page, the
|
||||
// native-text run reads the picture.
|
||||
let id = comment_only(&mut s);
|
||||
let skipped = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap();
|
||||
assert!(skipped[0].skipped.is_some(), "a comment is text for the default run");
|
||||
let found = s.recognize_text(id, &[], native.clone()).unwrap();
|
||||
assert_eq!(found[0].skipped, None, "{:?}", found[0]);
|
||||
assert!(found[0].text().to_lowercase().contains("brown"), "{:?}", found[0]);
|
||||
|
||||
// Page content over the picture is the page's own text: skipped, whatever the setting.
|
||||
let id = comment_only(&mut s);
|
||||
let (w, h) = {
|
||||
let p = &s.get(id).unwrap().info.pages[0];
|
||||
(p.width as f64, p.height as f64)
|
||||
};
|
||||
let note = AddedText { rect: [w * 0.1, h * 0.6, w * 0.9, h * 0.7], text: "Page text".into(), size: 18.0, ..Default::default() };
|
||||
s.apply(id, Edit::AddText { page: 0, text: note }).unwrap();
|
||||
let skipped = s.recognize_text(id, &[], native).unwrap();
|
||||
assert!(skipped[0].skipped.is_some(), "{:?}", skipped[0]);
|
||||
}
|
||||
|
||||
/// A form whose scripts are custom JavaScript: total = price × qty (calculate, through a
|
||||
/// document-level function), shown with a custom format; qty is validated; a button script.
|
||||
fn scripted_form() -> Vec<u8> {
|
||||
|
||||
@@ -41,7 +41,7 @@ impl Default for OcrDraft {
|
||||
impl OcrDraft {
|
||||
/// The recognition settings these choices make.
|
||||
pub fn settings(&self) -> OcrSettings {
|
||||
OcrSettings { dpi: self.dpi as f32, language: self.language.clone(), skip_text_pages: !self.text_pages }
|
||||
OcrSettings { dpi: self.dpi as f32, language: self.language.clone(), skip_text_pages: !self.text_pages, ..Default::default() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,9 +171,11 @@ impl PdfCraftApp {
|
||||
/// Edit on a scanned page: like Acrobat's "Converting scanned page to Editable Text and
|
||||
/// Images", the page's words are recognized first, so that they open for editing (Edit text &
|
||||
/// images groups the recognised words into phrases). Runs once per page, for the page in view
|
||||
/// while Edit is on (its tab, or the Edit text & images or Add text tool), and only for a page that is a picture (an image covering at least
|
||||
/// half of it, no text of its own: a page with text is skipped by the run). It is one undoable
|
||||
/// step, "Recognize text", and Undo is not offered again for that page.
|
||||
/// while Edit is on (its tab, or the Edit text & images or Add text tool). The page must be a
|
||||
/// picture (an image covering at least half of it) with no text of its own: comments and text
|
||||
/// boxes over it do not count as text, but real page text does, and such a page (a flyer with a
|
||||
/// background picture) is left alone. It is one undoable step, "Recognize text", and is not
|
||||
/// run again for that page after Undo.
|
||||
pub(crate) fn auto_ocr_scanned_page(&mut self) {
|
||||
// Edit is on when its panel is open (the Edit tab) or Edit text & images or Add text is picked,
|
||||
// the same test the page view uses for editing content.
|
||||
@@ -201,10 +203,12 @@ impl PdfCraftApp {
|
||||
if !covered || !pdfcraft_engine::ocr::available() {
|
||||
return;
|
||||
}
|
||||
// A picture on a page that has some text of its own (a comment, a text box) is still a
|
||||
// scan, as in Acrobat; the words the page's text already has are not added twice.
|
||||
// Only a page with no text of its own is read. Comments and text boxes over the picture do
|
||||
// not count as its text (a scan with a few comments is still a scan, as in Acrobat), but
|
||||
// a page with real text and a background picture is left alone.
|
||||
let mut settings = self.ocr_draft.settings();
|
||||
settings.skip_text_pages = false;
|
||||
settings.skip_text_pages = true;
|
||||
settings.native_text_only = true;
|
||||
self.start_ocr_pages(id, &[page], settings);
|
||||
}
|
||||
|
||||
|
||||
+31
-10
@@ -222,14 +222,38 @@ fn the_edit_panel_recognizes_a_scanned_page_too() {
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text"));
|
||||
}
|
||||
|
||||
/// A scanned page that also carries some text of its own (a comment or text box over the map) is
|
||||
/// still a scan: Edit reads the picture, and the page's own words are not added a second time.
|
||||
/// A scan with only a comment (a text box) over it is still a scan: Edit reads the picture. The
|
||||
/// comment's own words are not read into the layer, and it counts as no page text.
|
||||
#[test]
|
||||
fn editing_a_scan_that_carries_some_text_still_recognizes_the_picture_once() {
|
||||
fn editing_a_scan_with_a_comment_over_it_recognizes_the_picture() {
|
||||
if !pdfcraft_engine::ocr::available() {
|
||||
eprintln!("skipped: OCR models not installed");
|
||||
return;
|
||||
}
|
||||
let with_comment = {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("scan.pdf", None, std::sync::Arc::new(scan()), None).unwrap();
|
||||
let (w, h) = {
|
||||
let p = &s.get(id).unwrap().info.pages[0];
|
||||
(p.width as f64, p.height as f64)
|
||||
};
|
||||
let shape = pdfcraft_engine::Shape::TextBox { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], font_size: 18.0 };
|
||||
let style = pdfcraft_engine::Style::default_for(&shape);
|
||||
let note = pdfcraft_engine::NewAnnotation { page: 0, shape, style, contents: "Invoice reference 4711".into(), author: "Test".into() };
|
||||
s.apply(id, Edit::AddAnnotation(note)).unwrap();
|
||||
s.save_bytes(id).unwrap().to_vec()
|
||||
};
|
||||
let mut h = edit_harness(with_comment);
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text"));
|
||||
}
|
||||
|
||||
/// A born-digital page with a full-page background picture and real text (a flyer, a slide
|
||||
/// export) must not change just because Edit is turned on (maintainer review of #1609).
|
||||
#[test]
|
||||
fn editing_a_page_with_text_over_a_background_picture_changes_nothing() {
|
||||
let hybrid = {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("scan.pdf", None, std::sync::Arc::new(scan()), None).unwrap();
|
||||
@@ -245,13 +269,10 @@ fn editing_a_scan_that_carries_some_text_still_recognizes_the_picture_once() {
|
||||
let mut h = edit_harness(hybrid);
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text"));
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
|
||||
let text = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
|
||||
let text = text.text.unwrap().plain_text().to_lowercase();
|
||||
assert_eq!((text.matches("invoice").count(), text.matches("scanned").count()), (1, 1), "{text}");
|
||||
h.run_steps(6);
|
||||
assert!(h.state().ocr_run.is_none());
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "nothing was recognized or marked modified");
|
||||
assert!(!h.state().session.get(id).unwrap().dirty, "the file is not marked modified");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6517,7 +6517,7 @@ status = "partial"
|
||||
commands = ["edit.edit_text"]
|
||||
tools = ["text_edit"]
|
||||
evidence = ["crates/engine/tests/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/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, once per page per window; 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)."
|
||||
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)."
|
||||
|
||||
[[feature]]
|
||||
id = "ocr.ocr-downsample"
|
||||
|
||||
Reference in New Issue
Block a user