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 <noreply@anthropic.com>
This commit is contained in:
huppifluppandClaude Opus 5.5 committed 2026-10-28 13:37:00 +01:00
1 parent 308d22cbad
commit 622625a178
10 files changed
+176 -21

No files matched your search

+1 -1
View File
@@ -225,7 +225,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["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!({
+1 -1
View File
@@ -113,7 +113,7 @@ pub(crate) fn added_picture(cos: &Cos, file: &[u8]) -> Option<ObjRef> {
}
/// The largest raster image Form `form` draws through its resources (nested Forms included).
fn picture_in_form(cos: &Cos, form: ObjRef) -> Option<ObjRef> {
pub(crate) fn picture_in_form(cos: &Cos, form: ObjRef) -> Option<ObjRef> {
let mut seen = HashSet::from([form]);
let mut best: Option<(u64, ObjRef)> = None;
let res = resources(cos, form)?;
+23 -2
View File
@@ -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<Target> = 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) {
+53
View File
@@ -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| {
+57 -13
View File
@@ -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<Vec<(pdfcraft_edit::PageImage, f64)>> {
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<f64> {
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<f64> {
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);
+8 -1
View File
@@ -53,6 +53,9 @@ pub fn decode_samples(doc: &Document, s: &Stream) -> Result<Samples, NotDecoded>
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<Samples, NotDecoded>
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.
@@ -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());
}
}
@@ -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;
+4 -2
View File
@@ -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
+1 -1
View File
@@ -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"