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
@@ -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`@@ -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}");+ }+}@@ -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);