From 622625a178b9bec514b586f12b53fe1c28ab8f33 Mon Sep 17 00:00:00 2001 From: huppiflupp Date: Wed, 28 Oct 2026 13:37:00 +0100 Subject: [PATCH] M10: Enhance scanned file reports scans inside Form XObjects and names JPEG 2000 - A scan drawn only through grouped Form artwork is reported in skipped (once per form, with every page that draws it) instead of being counted as no scan; a Form of vector artwork holding only a small picture (under 36 dpi over its area) still counts as no scan. - decode_samples checks the filter before the colour space and bit depth, so a JPEG 2000 image without /ColorSpace or /BitsPerComponent is reported as JPEG 2000, not as a colour-space or bit-depth problem. - Optimize and Enhance scanned file no longer start while the other runs: they share the progress card and capture the same document state. - docs/gaps.md: background and edge-shadow removal apply to grey and RGB scans, despeckle also to 1-bit ones. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/tools.rs | 2 +- crates/engine/src/copy_image.rs | 2 +- crates/engine/src/scan_clean.rs | 25 ++++++- crates/engine/src/scan_clean/tests.rs | 53 ++++++++++++++ crates/engine/src/scan_fields.rs | 70 +++++++++++++++---- crates/optimize/src/samples.rs | 9 ++- crates/ui-egui/src/workflows/optimize_ui.rs | 23 ++++++ crates/ui-egui/src/workflows/scan_clean_ui.rs | 5 ++ docs/gaps.md | 6 +- parity/acrobat-features.toml | 2 +- 10 files changed, 176 insertions(+), 21 deletions(-) diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0d34911154..c718e4a47a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -225,7 +225,7 @@ pub fn tools() -> Vec { }), &["doc", "page"], )), - t("page_enhance_scan", "Enhance scanned file", "Clean up scanned pages in place (Scan & OCR ▸ Enhance ▸ Scanned document). A page is a scan when its pictures cover at least half of it; the pictures covering at least a tenth of it are cleaned and written back into the same image objects at the same size and colour space (a JPEG stays a JPEG at quality 90, other grey/RGB images are stored losslessly, a 1-bit image stays 1-bit), so the page's invisible OCR text, comments, links and other content stay as they were, and an image drawn on several pages is cleaned once. level (0–100, default 50) is background removal: the paper's illumination is evened out so a grey, yellowed or unevenly lit background turns white, then the contrast is raised so faint strokes darken; colour ink stays colour. edge_shadows (default: on unless level is 0) lightens shadows along the scan's edges (book gutters, a copier's dark border). despeckle (default: from level 60) turns isolated dark specks white; it is the only change a 1-bit scan takes. Returns pages_cleaned (1-based), images (rewritten), not_scanned (pages that are no scan) and skipped ([{pages, name, reason}]: CMYK, indexed, JPEG 2000, JBIG2 or CCITT images, broken data, or nothing to change). One undoable step; none when nothing changed.") + t("page_enhance_scan", "Enhance scanned file", "Clean up scanned pages in place (Scan & OCR ▸ Enhance ▸ Scanned document). A page is a scan when its pictures cover at least half of it; the pictures covering at least a tenth of it are cleaned and written back into the same image objects at the same size and colour space (a JPEG stays a JPEG at quality 90, other grey/RGB images are stored losslessly, a 1-bit image stays 1-bit), so the page's invisible OCR text, comments, links and other content stay as they were, and an image drawn on several pages is cleaned once. level (0–100, default 50) is background removal: the paper's illumination is evened out so a grey, yellowed or unevenly lit background turns white, then the contrast is raised so faint strokes darken; colour ink stays colour. edge_shadows (default: on unless level is 0) lightens shadows along the scan's edges (book gutters, a copier's dark border). despeckle (default: from level 60) turns isolated dark specks white; it is the only change a 1-bit scan takes. Returns pages_cleaned (1-based), images (rewritten), not_scanned (pages that are no scan) and skipped ([{pages, name, reason}]: CMYK, indexed, JPEG 2000, JBIG2 or CCITT images, a scan drawn inside grouped Form artwork, broken data, or nothing to change). One undoable step; none when nothing changed.") .cmd("ocr.enhance") .with(schema( json!({ diff --git a/crates/engine/src/copy_image.rs b/crates/engine/src/copy_image.rs index b377903ee4..1407c9f1d2 100644 --- a/crates/engine/src/copy_image.rs +++ b/crates/engine/src/copy_image.rs @@ -113,7 +113,7 @@ pub(crate) fn added_picture(cos: &Cos, file: &[u8]) -> Option { } /// The largest raster image Form `form` draws through its resources (nested Forms included). -fn picture_in_form(cos: &Cos, form: ObjRef) -> Option { +pub(crate) fn picture_in_form(cos: &Cos, form: ObjRef) -> Option { let mut seen = HashSet::from([form]); let mut best: Option<(u64, ObjRef)> = None; let res = resources(cos, form)?; diff --git a/crates/engine/src/scan_clean.rs b/crates/engine/src/scan_clean.rs index bfc8188b29..48ff15093a 100644 --- a/crates/engine/src/scan_clean.rs +++ b/crates/engine/src/scan_clean.rs @@ -12,7 +12,7 @@ //! pictures each covering at least a tenth of the page are its scan. What is not cleaned is //! reported, never skipped silently: pages that are no scan, and scan images in a form PdfCraft //! can't rewrite yet (CMYK, indexed, JPEG 2000, JBIG2, CCITT fax, a stencil mask, a remapping -//! decode array) or whose data is broken. +//! decode array, a scan drawn inside grouped Form artwork) or whose data is broken. //! //! Like Optimize, the work is a job: [`Session::scan_clean_job`] captures the document, //! [`ScanCleanJob::run`] cleans anywhere (a worker thread), and [`Session::apply_scan_clean`] @@ -31,6 +31,8 @@ const QUALITY: u8 = 90; /// On a scanned page, an image covering at least this share of the page is part of the scan; /// smaller ones (a logo or a signature pasted on) are left alone. const PART_OF_SCAN: f64 = 0.1; +/// Why a scan drawn through grouped Form artwork stays as it is. +const IN_FORM: &str = "it is drawn inside grouped artwork (a form XObject), which can't be cleaned up yet"; /// How scanned pages are cleaned up. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -138,10 +140,29 @@ impl Session { wanted.dedup(); let mut report = ScanCleanReport::default(); let mut targets: Vec = Vec::new(); + // Form artwork already reported, and where in `report.skipped`. + let mut forms_reported: Vec<(ObjRef, usize)> = Vec::new(); for page in wanted { let Some(info) = doc.info.pages.get(page) else { continue }; let Some(images) = crate::scan_fields::scan_images(doc, page, info) else { - report.not_scanned.push(page); + // A scan drawn through a Form XObject is a scan all the same: say why it stays. + let forms: Vec<_> = crate::scan_fields::scan_forms(doc, page, info).into_iter().filter(|(_, share)| *share >= PART_OF_SCAN).collect(); + if forms.is_empty() { + report.not_scanned.push(page); + } + for (form, _) in forms { + let known = form.object.and_then(|o| forms_reported.iter().find(|(r, _)| *r == o)).and_then(|(_, k)| report.skipped.get_mut(*k)); + match known { + Some(s) if !s.pages.contains(&page) => s.pages.push(page), + Some(_) => {} + None => { + if let Some(o) = form.object { + forms_reported.push((o, report.skipped.len())); + } + report.skipped.push(SkippedImage { pages: vec![page], name: form.name, reason: IN_FORM.into() }); + } + } + } continue; }; for (image, _) in images.into_iter().filter(|(_, share)| *share >= PART_OF_SCAN) { diff --git a/crates/engine/src/scan_clean/tests.rs b/crates/engine/src/scan_clean/tests.rs index 561ba9e2e4..f39f8c0432 100644 --- a/crates/engine/src/scan_clean/tests.rs +++ b/crates/engine/src/scan_clean/tests.rs @@ -275,6 +275,59 @@ fn hostile_images_are_reported_not_a_panic() { assert!(matches!(s.enhance_scanned_pages(id, &[99], ScanCleanSettings::default()), Err(EditError::Invalid(_)))); } +#[test] +fn a_scan_drawn_inside_a_form_xobject_is_reported_not_called_no_scan() { + // Pages 1 and 3 draw the scan through Form Fm0 (as some scanners write it), page 2 is text. + let page = |contents: usize| { + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R >> /Font << /F1 9 0 R >> >> /Contents {contents} 0 R >>" + ) + }; + let bytes = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R 7 0 R 10 0 R] /Count 3 >>") + .obj(page(6)) + .stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"), + format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"), + ) + .stream(&format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8"), scan_pixels(1)) + .stream("", "/Fm0 Do") + .obj(page(8)) + .stream("", "BT /F1 12 Tf 20 380 Td (Real text) Tj ET") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .obj(page(11)) + .stream("", "q /Fm0 Do Q") + .build(); + let (mut s, id) = open(bytes); + let before = object(&s, id, 5); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert!(report.pages.is_empty() && report.images == 0, "{report:?}"); + assert_eq!(report.not_scanned, vec![1], "only the text page is no scan"); + assert_eq!(report.skipped.len(), 1, "the form is reported once: {report:?}"); + let k = &report.skipped[0]; + assert_eq!((k.pages.as_slice(), k.name.as_str()), (&[0, 2][..], "Fm0")); + assert!(k.reason.contains("form XObject"), "{k:?}"); + assert_eq!(object(&s, id, 5), before, "the picture is left as it was"); + assert_eq!(s.get(id).unwrap().can_undo(), None); + + // A page of vector artwork in a Form, holding only a small logo, is no scan. + let logo = Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R >> >> /Contents 6 0 R >>")) + .stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"), + format!("0 0 {W} {H} re f q 40 0 0 20 10 370 cm /Im0 Do Q"), + ) + .stream("/Type /XObject /Subtype /Image /Width 40 /Height 20 /ColorSpace /DeviceGray /BitsPerComponent 8", [128; 800]) + .stream("", "/Fm0 Do") + .build(); + let (mut s, id) = open(logo); + let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap(); + assert_eq!((report.not_scanned.as_slice(), report.skipped.len()), (&[0][..], 0), "{report:?}"); +} + #[test] fn protected_documents_keep_their_encryption_and_restrictions() { let protect = |user: &str, permissions: i32| { diff --git a/crates/engine/src/scan_fields.rs b/crates/engine/src/scan_fields.rs index 769beaefca..d2e663f9a5 100644 --- a/crates/engine/src/scan_fields.rs +++ b/crates/engine/src/scan_fields.rs @@ -19,6 +19,9 @@ const SCALE: f32 = 2.0; const MAX_SIDE: f32 = 3000.0; /// A page is a scan when its images cover at least this share of it. const COVERED: f64 = 0.5; +/// A picture inside Form artwork is taken for a scan when it has at least this resolution over +/// the area the artwork covers (a quarter of the pixels a 72 dpi scan would have). +const MIN_FORM_DPI: f64 = 36.0; /// Recognised "words" less tall than this (points) are specks read as letters, and would make /// the box they sit on look filled in. const MIN_WORD_HEIGHT: f64 = 4.0; @@ -32,28 +35,69 @@ pub(crate) fn is_scan(doc: &Document, page: usize, info: &PageInfo) -> bool { /// crop box) it covers, when together they cover most of it ([`is_scan`]); `None` otherwise. /// Enhance ▸ Scanned document cleans these up ([`crate::scan_clean`]). pub(crate) fn scan_images(doc: &Document, page: usize, info: &PageInfo) -> Option> { - let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); - let page_area = (cx1 - cx0) * (cy1 - cy0); - if !page_area.is_finite() || page_area <= 0.0 { - return None; - } + let area = page_area(info)?; let images: Vec<(pdfcraft_edit::PageImage, f64)> = doc .page_images(page) .into_iter() .filter(|i| !i.is_form && i.width > 1 && i.height > 1) - .map(|i| { - let r = i.visible_rect.unwrap_or(i.rect); - let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0); - let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0); - let share = w * h / page_area; - (i, share) - }) - .filter(|(_, share)| share.is_finite()) + .filter_map(|i| Some((share(info, area, &i)?, i))) + .map(|(share, i)| (i, share)) .collect(); let covered: f64 = images.iter().map(|(_, share)| share).sum(); (covered >= COVERED).then_some(images) } +/// The grouped Form artwork on `page` that holds a raster picture, each with the share of the +/// page it covers, when it and the page's own images together cover most of the page: a scan +/// drawn through a Form XObject, which [`scan_images`] does not count and Enhance ▸ Scanned +/// document can't clean yet, so it reports these. Empty otherwise. +pub(crate) fn scan_forms(doc: &Document, page: usize, info: &PageInfo) -> Vec<(pdfcraft_edit::PageImage, f64)> { + let (Some(area), Some(editor)) = (page_area(info), doc.editor.as_ref()) else { return Vec::new() }; + let mut covered = 0.0; + let mut forms = Vec::new(); + for i in doc.page_images(page) { + let Some(share) = share(info, area, &i) else { continue }; + if !i.is_form { + if i.width > 1 && i.height > 1 { + covered += share; + } + } else if i.object.is_some_and(|form| holds_scan(&editor.cos, form, share * area)) { + covered += share; + forms.push((i, share)); + } + } + if covered >= COVERED { forms } else { Vec::new() } +} + +/// Whether Form `form`, covering `points` square points of the page, draws a picture that could +/// be a scan of it: one with at least [`MIN_FORM_DPI`] over that area, not a logo inside a page +/// of vector artwork. +fn holds_scan(cos: &pdfcraft_cos::Document, form: pdfcraft_cos::ObjRef, points: f64) -> bool { + let Some(picture) = crate::copy_image::picture_in_form(cos, form) else { return false }; + let obj = cos.get(picture); + let pdfcraft_cos::Object::Stream(s) = &*obj else { return false }; + let px = |k: &[u8]| s.dict.int(k).unwrap_or(0).max(0) as f64; + let per_point = MIN_FORM_DPI / 72.0; + px(b"Width") * px(b"Height") >= points * per_point * per_point +} + +/// The area of `info`'s crop box, when it is a usable one. +fn page_area(info: &PageInfo) -> Option { + let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); + let area = (cx1 - cx0) * (cy1 - cy0); + (area.is_finite() && area > 0.0).then_some(area) +} + +/// The share of the crop box (of area `area`) that `image` covers, when it is a number. +fn share(info: &PageInfo, area: f64, image: &pdfcraft_edit::PageImage) -> Option { + let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from); + let r = image.visible_rect.unwrap_or(image.rect); + let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0); + let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0); + let share = w * h / area; + share.is_finite().then_some(share) +} + /// The page as displayed, in points with y up: where detection on a scan works. struct Frame<'a>(&'a PageInfo); diff --git a/crates/optimize/src/samples.rs b/crates/optimize/src/samples.rs index 7f402b51b8..1ed9f16b1f 100644 --- a/crates/optimize/src/samples.rs +++ b/crates/optimize/src/samples.rs @@ -53,6 +53,9 @@ pub fn decode_samples(doc: &Document, s: &Stream) -> Result if matches!(d.get(b"ImageMask").map(|v| doc.resolve(v)).as_deref(), Some(Object::Bool(true))) { return Err(NotDecoded::StencilMask); } + // The filter first: a JPEG 2000 image may leave its colour space and bit depth to the data, + // and the report should name the format, not the missing entries. + let jpeg = is_dct(s).ok_or(NotDecoded::Filter)?; let n = d.get(b"ColorSpace").and_then(|cs| components(doc, cs)).ok_or(NotDecoded::ColourSpace)?; let bpc = d.get(b"BitsPerComponent").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).unwrap_or(0); let bilevel = match bpc { @@ -60,7 +63,6 @@ pub fn decode_samples(doc: &Document, s: &Stream) -> Result 1 if n == 1 => true, other => return Err(NotDecoded::BitDepth(other)), }; - let jpeg = is_dct(s).ok_or(NotDecoded::Filter)?; let width = d.get(b"Width").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); let height = d.get(b"Height").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); if width == 0 || height == 0 { @@ -258,6 +260,11 @@ mod tests { assert_eq!(base(&[("ColorSpace", Object::name("DeviceCMYK"))], vec![0; 64]), Err(NotDecoded::ColourSpace)); assert_eq!(base(&[("BitsPerComponent", Object::Int(4))], vec![0; 8]), Err(NotDecoded::BitDepth(4))); assert_eq!(base(&[("Filter", Object::name("JBIG2Decode"))], vec![0; 8]), Err(NotDecoded::Filter)); + // JPEG 2000 may leave the colour space and bit depth to its data: the format is the reason. + let jpx = |dict: &[(&str, Object)]| decode_samples(&doc(), &image(dict, vec![0; 8])); + let size = [("Width", Object::Int(4)), ("Height", Object::Int(4)), ("Filter", Object::name("JPXDecode"))]; + assert_eq!(jpx(&size), Err(NotDecoded::Filter)); + assert_eq!(jpx(&[size[0].clone(), size[1].clone(), size[2].clone(), ("ColorSpace", Object::name("DeviceRGB"))]), Err(NotDecoded::Filter)); assert_eq!(base(&[("ImageMask", Object::Bool(true))], vec![0; 8]), Err(NotDecoded::StencilMask)); assert_eq!(base(&[("Decode", Object::Array(vec![Object::Int(1), Object::Int(0)]))], vec![0; 16]), Err(NotDecoded::Decode)); // Too short, a huge size, a broken JPEG, no size at all. diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index 3bb80ac439..4e1a7c20bf 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -360,6 +360,11 @@ impl PdfCraftApp { self.notify_tr("Optimization is already running"); return; } + // Both jobs share the progress card and capture the same document state. + if self.scan_clean_run.is_some() { + self.notify_tr("Scanned pages are already being enhanced"); + return; + } let job = match self.session.optimize_job(id, settings, discard) { Ok(job) => job, Err(e) => return self.save_optimized(id, kind, Err(e)), @@ -491,4 +496,22 @@ mod tests { app.optimize_with_draft(); assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running")); } + + #[test] + fn optimize_and_enhance_scanned_file_do_not_run_at_once() { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("notes.txt", None, b"hello".to_vec()).unwrap(); + let (_, doc) = app.active_ids().unwrap(); + app.scan_clean_run = Some(crate::scan_clean_ui::ScanCleanRun { doc, progress: Arc::default() }); + app.optimize_with_draft(); + assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Scanned pages are already being enhanced")); + assert!(app.optimize_run.is_none()); + + app.scan_clean_run = None; + app.optimize_run = Some(OptimizeRun { doc, kind: OptimizeKind::Reduce, progress: Arc::default() }); + app.start_scan_clean(); + assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Optimization is already running")); + assert!(app.scan_clean_run.is_none()); + } } diff --git a/crates/ui-egui/src/workflows/scan_clean_ui.rs b/crates/ui-egui/src/workflows/scan_clean_ui.rs index a275b5af20..64f8ce48eb 100644 --- a/crates/ui-egui/src/workflows/scan_clean_ui.rs +++ b/crates/ui-egui/src/workflows/scan_clean_ui.rs @@ -140,6 +140,11 @@ impl PdfCraftApp { self.notify_tr("Scanned pages are already being enhanced"); return; } + // Both jobs share the progress card and capture the same document state. + if self.optimize_run.is_some() { + self.notify_tr("Optimization is already running"); + return; + } // What's typed in a form field is part of the document (#166). if !self.commit_form_typing() { return; diff --git a/docs/gaps.md b/docs/gaps.md index ab6f083ab1..dacca2515b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -165,8 +165,10 @@ update the numbers. ### 7. OCR reads only unaccented Latin - **Missing:** every other script and accented Latin; editable-text output; deskew, rotation, - despeckle and background clean-up as options of Recognize text (Enhance scanned file does them - on grey, RGB and 1-bit scans, not CMYK, indexed, JBIG2 or CCITT ones); MRC compression; + despeckle and background clean-up as options of Recognize text (Enhance scanned file removes + the background and edge shadows on grey and RGB scans and despeckles those and 1-bit ones; it + reports CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans and scans drawn inside Form XObjects + and leaves them as they are); MRC compression; pluggable engines; OCR in the browser; scanner input. - **Evidence:** `ocr.ocr-languages` partial ("the models read only the Latin alphabet without diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 1ea04254aa..5f48c815a9 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6677,7 +6677,7 @@ status = "partial" commands = ["ocr.enhance_camera", "ocr.enhance"] tools = ["page_enhance_camera", "page_enhance_scan"] evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::despeckle_is_its_own_switch", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did"] -notes = "Camera images, and scanned pages through Enhance scanned file (ocr.enhance, page_enhance_scan despeckle): isolated dark pixels (at most one dark neighbour) turn white, on grey, RGB and 1-bit scans, in place (the OCR text layer, comments and links stay). Missing: as an option of Recognize text; specks larger than two pixels; CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans (reported, not changed)." +notes = "Camera images, and scanned pages through Enhance scanned file (ocr.enhance, page_enhance_scan despeckle): isolated dark pixels (at most one dark neighbour) turn white, on grey, RGB and 1-bit scans, in place (the OCR text layer, comments and links stay). Missing: as an option of Recognize text; specks larger than two pixels; CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans and scans inside form XObjects (reported, not changed)." [[feature]] id = "ocr.background-removal"