collective

Browse / github.com/storytold/pdfcraft / patch

Compare: say so when a document has no text (scans)

Compare: say so when a document has no text (scans) ## Summary - `Session::compare` now refuses a document that has no extractable text (a scan or a blank page) with a message naming the file and pointing to Recognize text or the visual compare. Before, comparing a scan against anything reported "The text is the same" or every word as inserted or deleted, which is a false answer. - It covers the report and mark-as-comments paths too, since both go through `compare`. The UI and the `doc_compare` tool already show the error. - This is separate from #3766 (page realignment) and touches only the engine's `compare`, so it should not conflict with it

Status
open
Score
0.00
Adopters
0 other operators
Author
Harrison-Booth (imported from pull request #5052, open)
Base
9e90b2a54d6250465ba3d11a6eb0b13f3b690381
Size
+109 −70 in 3 files
Review
pending
Shared
2026-10-28
Id
4937847c6beee7729e85ef0e

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/compare.rs crates/engine/src/tests.rs crates/ui-egui/tests/it/view.rs

diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs--- a/crates/engine/src/compare.rs+++ b/crates/engine/src/compare.rs@@ -60,7 +60,17 @@ impl Session {         let b = self.get(new).ok_or(EditError::NoDocument)?;         a.check_copying()?;         b.check_copying()?;-        Ok(pdfcraft_compare::compare(&a.words(), &b.words()))+        let (old_words, new_words) = (a.words(), b.words());+        // A scanned page is a picture: with no text to read, "no changes" would be a false answer.+        for (d, words) in [(a, &old_words), (b, &new_words)] {+            if words.is_empty() {+                return Err(EditError::Invalid(format!(+                    "{} has no text to compare (it may be a scan). Run Recognize text on it first, or use the visual compare.",+                    d.name+                )));+            }+        }+        Ok(pdfcraft_compare::compare(&old_words, &new_words))     }      /// Visual compare: regions where page n of `new` looks different from page n of `old`diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs--- a/crates/engine/src/tests.rs+++ b/crates/engine/src/tests.rs@@ -5196,6 +5196,20 @@ fn detecting_comb_boxes_adds_one_comb_field_per_row() {     assert_eq!(d.can_undo(), Some("Detect form fields")); } +#[test]+fn comparing_a_document_without_text_says_so_instead_of_reporting_no_changes() {+    let mut s = Session::new().with_clock(|| 1_700_000_000);+    let text = s.create_from_text("t", "The contract starts on Monday.").unwrap();+    let scan = s.create_blank(200.0, 300.0, 1).unwrap();+    let text = s.open("text.pdf", None, text, None).unwrap();+    let scan = s.open("scan.pdf", None, scan, None).unwrap();+    for (old, new) in [(text, scan), (scan, text), (scan, scan)] {+        let Err(EditError::Invalid(m)) = s.compare(old, new) else { panic!("a document without text is refused") };+        assert!(m.contains("scan.pdf has no text to compare") && m.contains("Recognize text"), "{m}");+    }+    assert!(s.compare(text, text).is_ok());+}+ #[test] fn comparing_two_versions_of_a_document() {     let mut s = Session::new().with_clock(|| 1_700_000_000);@@ -8720,69 +8734,69 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec<u8> {     bytes } -/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its-/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the-/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked-/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without-/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was.-#[test]-fn need_appearances_buttons_are_displayed_from_their_captions() {-    let pdf = |need: bool| {-        let flag = if need { "/NeedAppearances true " } else { "" };-        format!(-            "%PDF-1.7\n\-             1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\-             2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\-             3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\-             4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\-             5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\-             6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\-             7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\-             8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\-             9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\-             trailer << /Root 1 0 R >>\n%%EOF"-        )-        .into_bytes()-    };-    let render = |bytes: Arc<Vec<u8>>| {-        let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default());-        r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })-    };-    // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1.-    let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| {-        let (px, _) = page.rgba.as_chunks::<4>();-        (200 - y1..200 - y0)-            .flat_map(|y| (x0..x1).map(move |x| (x, y)))-            .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128))-            .count()-    };-    // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside-    // (where a check mark leaves the box empty), and the lower-left corner (outside a dot).-    let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34);-    let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34);-    let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9);-    let mut s = Session::new();-    let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();-    let shown = render(s.get(id).unwrap().display.clone());-    let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };-    let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();-    let printed = render(Arc::new(pictures));-    for (page, how) in [(&shown, "shown"), (&printed, "printed")] {-        let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100)));-        assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}");-        assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty");-        assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty");-        assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border");-        let dot = ink(page, (188, 108, 212, 132));-        assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}");-    }-    let saved = s.save_bytes(id).unwrap();-    assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances");--    // Without /NeedAppearances the file's filled squares are shown.-    let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap();-    let shown = render(s.get(id).unwrap().display.clone());-    for x in [20, 100, 180] {-        assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}");-    }-}+/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its+/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the+/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked+/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without+/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was.+#[test]+fn need_appearances_buttons_are_displayed_from_their_captions() {+    let pdf = |need: bool| {+        let flag = if need { "/NeedAppearances true " } else { "" };+        format!(+            "%PDF-1.7\n\+             1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\+             2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\+             3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\+             4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\+             5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\+             6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\+             7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\+             8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\+             9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\+             trailer << /Root 1 0 R >>\n%%EOF"+        )+        .into_bytes()+    };+    let render = |bytes: Arc<Vec<u8>>| {+        let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default());+        r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })+    };+    // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1.+    let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| {+        let (px, _) = page.rgba.as_chunks::<4>();+        (200 - y1..200 - y0)+            .flat_map(|y| (x0..x1).map(move |x| (x, y)))+            .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128))+            .count()+    };+    // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside+    // (where a check mark leaves the box empty), and the lower-left corner (outside a dot).+    let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34);+    let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34);+    let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9);+    let mut s = Session::new();+    let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();+    let shown = render(s.get(id).unwrap().display.clone());+    let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };+    let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();+    let printed = render(Arc::new(pictures));+    for (page, how) in [(&shown, "shown"), (&printed, "printed")] {+        let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100)));+        assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}");+        assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty");+        assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty");+        assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border");+        let dot = ink(page, (188, 108, 212, 132));+        assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}");+    }+    let saved = s.save_bytes(id).unwrap();+    assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances");++    // Without /NeedAppearances the file's filled squares are shown.+    let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap();+    let shown = render(s.get(id).unwrap().display.clone());+    for x in [20, 100, 180] {+        assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}");+    }+}diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs--- a/crates/ui-egui/tests/it/view.rs+++ b/crates/ui-egui/tests/it/view.rs@@ -16,6 +16,21 @@ const PAGES: &[u8] = b"%PDF-1.7 trailer << /Root 1 0 R >> %%EOF"; +/// [`PAGES`] with a line of text on page 2: compare refuses a document that has none.+const PAGES_WITH_TEXT: &[u8] = b"%PDF-1.7+1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj+2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] /Resources << /Font << /F1 7 0 R >> >> >> endobj+3 0 obj << /Type /Page /Parent 2 0 R /Annots [6 0 R] >> endobj+4 0 obj << /Type /Page /Parent 2 0 R /Contents 8 0 R >> endobj+5 0 obj << /Type /Page /Parent 2 0 R >> endobj+6 0 obj << /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /Dest [5 0 R /Fit] >> endobj+7 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj+8 0 obj << /Length 44 >> stream+BT /F1 12 Tf 40 300 Td (Hello compare) Tj ET+endstream endobj+trailer << /Root 1 0 R >>+%%EOF";+ use crate::gpu;  fn harness(options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> {@@ -185,8 +200,8 @@ fn page_gaps_comparison_harness() -> Harness<'static, PdfCraftApp> {         let mut app = PdfCraftApp::new();         app.set_option("language", "en").unwrap();         app.set_option("left", "closed").unwrap();-        app.open_bytes("old.pdf", None, PAGES.to_vec()).unwrap();-        app.open_bytes("new.pdf", None, PAGES.to_vec()).unwrap();+        app.open_bytes("old.pdf", None, PAGES_WITH_TEXT.to_vec()).unwrap();+        app.open_bytes("new.pdf", None, PAGES_WITH_TEXT.to_vec()).unwrap();         app.set_option("zoom", "50").unwrap();         app.compare_old = Some(app.views[0].id);         app.run_compare();@@ -222,7 +237,7 @@ fn page_gaps_comparison_control_preserves_both_states() {         h.run_steps(4);         assert!(h.state().views.iter().all(|v| v.show_page_gaps == show), "comparison rejected page-gaps={value}");     }-    h.state_mut().open_bytes("unrelated.pdf", None, PAGES.to_vec()).unwrap();+    h.state_mut().open_bytes("unrelated.pdf", None, PAGES_WITH_TEXT.to_vec()).unwrap();     h.state_mut().set_option("page-gaps", "off").unwrap();     assert!(h.state().views[0].show_page_gaps && h.state().views[1].show_page_gaps);     assert!(!h.state().views[2].show_page_gaps);