From 5b220eb8868bb344da865fc6f59de7ada9331aa5 Mon Sep 17 00:00:00 2001 From: alexanderalber <90604107+alexanderalber@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:21:42 +0200 Subject: [PATCH] M6: detect form fields on scanned pages (#1721) (#1722) Field detection read only vector data: text-layer words and drawn rectangles and lines. A scanned form is a single picture, so it found nothing there. A page that an image covers at least half of, and whose content draws no lines, is now rendered at 144 dpi and its shapes are read from the pixels (pdfcraft_forms::detect::raster): ink against a local mean, long thin runs as lines, enclosed rectangular patches of paper as boxes, the empty band of a box with a small printed label as its writing area, and the open-ended horizontal lines as rules. Labels come from the text layer, else from OCR when the models are installed; without either the fields get default names. Detection works in the displayed frame, so a scan stored sideways reads like any other, and fields on a turned page get /MK /R so typing reads upright. generated with Claude Code --- crates/automation/src/tools.rs | 2 +- crates/engine/src/js.rs | 60 +++- crates/engine/src/lib.rs | 1 + crates/engine/src/scan_fields.rs | 144 ++++++++ crates/engine/src/tests.rs | 149 ++++++++ crates/forms/src/detect.rs | 4 +- crates/forms/src/detect/raster.rs | 550 ++++++++++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 8 files changed, 893 insertions(+), 21 deletions(-) create mode 100644 crates/engine/src/scan_fields.rs create mode 100644 crates/forms/src/detect/raster.rs diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1ba549cf0..98bf2754a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -1026,7 +1026,7 @@ pub fn tools() -> Vec { t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.") .cmd("doc.compare") .with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])), - t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).") + t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).") .cmd("form.detect") .with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])), t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).") diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index d636c303f..8958688d2 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -250,6 +250,15 @@ fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::Pag /// A page's words in reading order, in user space ([x0, y0, x1, y1]); runs of underscores are /// words of their own. pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec<(String, [f64; 4])> { + words_with(text, |x, y| info.view_to_user(x, y)) + .into_iter() + .map(|(text, r)| (text, [f64::from(r[0]), f64::from(r[1]), f64::from(r[2]), f64::from(r[3])])) + .collect() +} + +/// A page's words in reading order, their boxes mapped from view space by `map` (and ordered +/// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them). +pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> { let mut words: Vec<(String, [f32; 4])> = Vec::new(); let mut last_line = u32::MAX; // A space glyph ends the word before it. @@ -281,18 +290,23 @@ pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_rende words .into_iter() .map(|(text, r)| { - let a = info.view_to_user(r[0], r[1]); - let b = info.view_to_user(r[2], r[3]); - let rect = [a[0].min(b[0]) as f64, a[1].min(b[1]) as f64, a[0].max(b[0]) as f64, a[1].max(b[1]) as f64]; - (text, rect) + let a = map(r[0], r[1]); + let b = map(r[2], r[3]); + (text, [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]) }) .collect() } impl Session { /// Prepare a form ▸ automatic field detection: the fields `pages` (0-based; empty = all) - /// seem to ask for, named from their labels, as (page, candidate). + /// seem to ask for, named from their labels, as (page, candidate). Scanned pages (a picture + /// and no drawn shapes) are read from their pixels, see [`crate::scan_fields`]. pub fn detect_fields(&self, id: DocId, pages: &[usize]) -> Vec<(usize, pdfcraft_forms::detect::Candidate)> { + self.detect_fields_turned(id, pages).into_iter().map(|(page, c, _)| (page, c)).collect() + } + + /// [`Self::detect_fields`], with how far each field turns (`/MK /R`) to read upright. + fn detect_fields_turned(&self, id: DocId, pages: &[usize]) -> Vec<(usize, pdfcraft_forms::detect::Candidate, i64)> { let Some(doc) = self.get(id) else { return Vec::new() }; let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Vec::new() }; let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() }; @@ -302,13 +316,19 @@ impl Session { let list: Vec = if pages.is_empty() { (0..all).collect() } else { pages.iter().copied().filter(|p| *p < all).collect() }; let mut out = Vec::new(); for page in list { + let Some(info) = doc.info.pages.get(page) else { continue }; let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); - let words = res.text.map(|t| detection_words(&t, &doc.info.pages[page])).unwrap_or_default(); let shapes = pdfcraft_forms::detect::page_shapes(cos, page); let existing: Vec<[f64; 4]> = doc.form.iter().flat_map(|f| f.widgets.iter()).filter(|w| w.page == Some(page)).map(|w| w.rect).collect(); - for c in pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken) { + let (found, turn) = if shapes.boxes.is_empty() && shapes.rules.is_empty() && crate::scan_fields::is_scan(doc, page, info) { + self.detect_on_scan(id, &mut r, page, res.text.as_deref(), &existing, &taken) + } else { + let words = res.text.map(|t| detection_words(&t, info)).unwrap_or_default(); + (pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken), 0) + }; + for c in found { taken.push(c.name.clone()); - out.push((page, c)); + out.push((page, c, turn)); } } out @@ -316,23 +336,29 @@ impl Session { /// Detect fields and add them, as one undoable step. Returns the new fields' names. pub fn auto_detect_fields(&mut self, id: DocId, pages: &[usize]) -> Result, EditError> { - let found = self.detect_fields(id, pages); + let found = self.detect_fields_turned(id, pages); if found.is_empty() { return Ok(Vec::new()); } - let names = found.iter().map(|(_, c)| c.name.clone()).collect(); - let edits = found - .into_iter() - .map(|(page, c)| Edit::AddField { + let names = found.iter().map(|(_, c, _)| c.name.clone()).collect(); + let mut edits = Vec::with_capacity(found.len()); + for (page, c, turn) in found { + // A turned field keeps its place: the rectangle is already the one on the page. + let props = (turn != 0).then(|| pdfcraft_forms::FieldProps { rotation: Some((0, turn)), rect: Some((0, c.rect)), ..Default::default() }); + let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] }; + edits.push(Edit::AddField { page, rect: c.rect, kind: match c.kind { - pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: c.rect[3] - c.rect[1] > 30.0 }, + pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox, }, - name: Some(c.name), - }) - .collect(); + name: Some(c.name.clone()), + }); + if let Some(props) = props { + edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) }); + } + } self.apply(id, Edit::Batch { label: "Detect form fields".into(), edits })?; Ok(names) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f170a07d1..b3b9a1e03 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -30,6 +30,7 @@ pub mod ocr; pub mod optimizer; pub mod printcheck; mod scan_colours; +mod scan_fields; pub mod signature_image; pub mod signature_scan; pub mod text_fonts; diff --git a/crates/engine/src/scan_fields.rs b/crates/engine/src/scan_fields.rs new file mode 100644 index 000000000..8287743fc --- /dev/null +++ b/crates/engine/src/scan_fields.rs @@ -0,0 +1,144 @@ +//! Prepare a form ▸ field detection on scanned pages. A page that is a picture has no text and +//! no drawn shapes to read, so it is rendered and its lines and boxes are read from the pixels +//! ([`pdfcraft_forms::detect::raster`]). The labels come from the page's text layer (an earlier +//! Recognize text), else from text recognition when its models are installed. Without either, +//! the fields are still found and get the default names (Text1, Check Box1, …). +//! +//! Detection works in the page as displayed (after `/Rotate`), in points with y up, so a scan +//! stored sideways and turned upright by `/Rotate` reads like any other page. The fields are +//! mapped back to user space and turned with the page (`/MK /R`), so typed text reads upright. + +use pdfcraft_forms::detect::{Candidate, Word, detect, raster}; +use pdfcraft_render::{PageInfo, PageRenderer, PageText, RenderRequest}; + +use crate::{DocId, Document, Session, ocr}; + +/// Pixels per point the page is rendered at for its lines (144 dpi). +const SCALE: f32 = 2.0; +/// The longest side of that rendering, in pixels. +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; +/// 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; + +/// Whether `page` is a picture of a page: images cover most of it. +pub(crate) fn is_scan(doc: &Document, page: usize, info: &PageInfo) -> bool { + 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 false; + } + let covered: f64 = doc + .page_images(page) + .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); + w * h + }) + .filter(|a| a.is_finite()) + .sum(); + covered >= COVERED * page_area +} + +/// The page as displayed, in points with y up: where detection on a scan works. +struct Frame<'a>(&'a PageInfo); + +impl Frame<'_> { + /// The box around view-space points (y down). + fn view_box(&self, points: impl IntoIterator) -> Option<[f64; 4]> { + let h = f64::from(self.0.height); + let mut out = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; + for [x, y] in points { + let (x, y) = (f64::from(x), h - f64::from(y)); + out = [out[0].min(x), out[1].min(y), out[2].max(x), out[3].max(y)]; + } + out.iter().all(|v| v.is_finite()).then_some(out) + } + + /// A user-space rectangle (or the box around user-space points) in the frame. + fn user_box(&self, points: impl IntoIterator) -> Option<[f64; 4]> { + self.view_box(points.into_iter().map(|[x, y]| self.0.user_to_view(x as f32, y as f32))) + } + + /// A frame rectangle in user space. + fn user_rect(&self, r: [f64; 4]) -> [f64; 4] { + let h = f64::from(self.0.height); + let a = self.0.view_to_user(r[0] as f32, (h - r[1]) as f32); + let b = self.0.view_to_user(r[2] as f32, (h - r[3]) as f32); + [f64::from(a[0].min(b[0])), f64::from(a[1].min(b[1])), f64::from(a[0].max(b[0])), f64::from(a[1].max(b[1]))] + } +} + +fn corners(r: [f64; 4]) -> [[f64; 2]; 4] { + [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]] +} + +impl Session { + /// The fields on scanned `page` of `id`, in user space, and how far the page turns them + /// (its `/Rotate`). `text` is the page's text layer, `existing` its widget rectangles. + pub(crate) fn detect_on_scan( + &self, + id: DocId, + r: &mut PageRenderer, + page: usize, + text: Option<&PageText>, + existing: &[[f64; 4]], + taken: &[String], + ) -> (Vec, i64) { + let Some(info) = self.get(id).and_then(|d| d.info.pages.get(page)) else { return (Vec::new(), 0) }; + let frame = Frame(info); + let scale = SCALE.min(MAX_SIDE / info.width.max(info.height).max(1.0)); + let shot = r.render(RenderRequest { page, scale, ..Default::default() }); + if shot.error.is_some() || shot.width == 0 { + return (Vec::new(), 0); + } + let px_per_pt = f64::from(shot.width) / f64::from(info.width.max(1e-3)); + let shapes = raster::shapes(&shot.rgba, shot.width, shot.height, px_per_pt); + if shapes.boxes.is_empty() && shapes.rules.is_empty() { + return (Vec::new(), 0); + } + let mut words: Vec = text + .map(|t| crate::js::words_with(t, |x, y| [x, y])) + .unwrap_or_default() + .into_iter() + .filter_map(|(text, v)| Some(Word { text, rect: frame.view_box([[v[0], v[1]], [v[2], v[3]]])? })) + .collect(); + if words.is_empty() { + words = self.recognized_words(id, page, &frame); + } + let existing: Vec<[f64; 4]> = existing.iter().filter_map(|r| frame.user_box(corners(*r))).collect(); + let found = detect(&words, &shapes, &existing, taken); + let turn = i64::from(info.rotation % 360); + (found.into_iter().map(|c| Candidate { rect: frame.user_rect(c.rect), ..c }).collect(), turn) + } + + /// The words text recognition reads on `page`, in the frame; none without the models. + fn recognized_words(&self, id: DocId, page: usize, frame: &Frame) -> Vec { + if !ocr::available() { + return Vec::new(); + } + let (Ok(engine), Some(job)) = (ocr::engine(), self.ocr_job(id, &[page], ocr::OcrSettings::default())) else { + return Vec::new(); + }; + job.run(&engine, |_, _| true) + .into_iter() + .flat_map(|p| p.words) + .filter_map(|w| { + let o = w.origin; + let points = [ + o, + [o[0] + w.across[0], o[1] + w.across[1]], + [o[0] + w.up[0], o[1] + w.up[1]], + [o[0] + w.across[0] + w.up[0], o[1] + w.across[1] + w.up[1]], + ]; + Some(Word { rect: frame.user_box(points)?, text: w.text }) + }) + .filter(|w| w.rect[3] - w.rect[1] >= MIN_WORD_HEIGHT) + .collect() + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 133587202..2711d19f7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2990,6 +2990,155 @@ fn detecting_fields_on_a_printed_form() { assert!(s.detect_fields(id, &[]).is_empty(), "nothing left to detect"); } +/// A printed form, 420 × 300 pt: a title, a label with a rule to write on, a box with a small +/// label in its corner, an empty box with its label above, and two check boxes. +fn paper_form() -> Vec { + let content = "BT /F1 14 Tf 30 270 Td (Gewerbe-Ummeldung) Tj ET \ + BT /F1 10 Tf 30 230 Td (Name:) Tj ET 0.75 w 80 228 m 380 228 l S \ + 1 w 30 150 350 50 re S BT /F1 7 Tf 34 191 Td (Anschrift) Tj ET \ + BT /F1 8 Tf 30 124 Td (Telefon) Tj ET 30 90 350 30 re S \ + 30 50 10 10 re S BT /F1 9 Tf 46 51 Td (Haupt) Tj ET 150 50 10 10 re S BT /F1 9 Tf 166 51 Td (Zweig) Tj ET"; + let objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 420 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, obj) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(obj); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// How the paper form was scanned. +#[derive(Clone, Copy, Debug)] +enum Scan { + Straight, + /// Stored sideways and turned upright by `/Rotate 90`. + Sideways, + /// Speckled paper, uneven light and lines that lose some pixels. + Noisy, + /// Laid on the scanner a degree askew. + Skewed, +} + +/// The paper form as an image-only PDF at 150 dpi (the page keeps its 420 × 300 pt). +fn scanned_form(scan: Scan) -> Arc> { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("paper.pdf", None, Arc::new(paper_form()), None).unwrap(); + let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap(); + let mut img = image::load_from_memory(&png).unwrap().to_rgba8(); + match scan { + Scan::Straight => {} + Scan::Sideways => img = image::imageops::rotate270(&img), + Scan::Skewed => { + // Turned a degree about the centre (nearest pixel; the corners stay paper). + let (w, h) = (img.width() as f64, img.height() as f64); + let (sin, cos) = 1f64.to_radians().sin_cos(); + let src = img.clone(); + for (x, y, p) in img.enumerate_pixels_mut() { + let (dx, dy) = (x as f64 - w / 2.0, y as f64 - h / 2.0); + let (sx, sy) = (cos * dx + sin * dy + w / 2.0, -sin * dx + cos * dy + h / 2.0); + *p = if sx >= 0.0 && sy >= 0.0 && sx < w && sy < h { *src.get_pixel(sx as u32, sy as u32) } else { image::Rgba([255; 4]) }; + } + } + Scan::Noisy => { + let (w, mut seed) = (img.width(), 7u32); + for (x, _, p) in img.enumerate_pixels_mut() { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345); + let noise = ((seed >> 16) % 48) as u8; + let shade = (x * 50 / w) as u8; + for c in &mut p.0[..3] { + *c = if *c < 128 && noise < 4 { 230 } else { c.saturating_sub(shade).saturating_sub(noise) }; + } + } + } + } + let mut bytes = std::io::Cursor::new(Vec::new()); + img.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + let pdf = s.create_from_images_with_resolution(&[("scan.png".into(), bytes.into_inner())], ImageResolution::Dpi(150.0)).unwrap(); + if !matches!(scan, Scan::Sideways) { + return pdf; + } + let id = s.open("scan.pdf", None, pdf, None).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.save_bytes(id).unwrap() +} + +/// Fields on a scanned paper form are found from its lines and boxes, wherever the page turns +/// them, and named from the labels when text recognition is installed. +#[test] +fn detecting_fields_on_a_scanned_form() { + use pdfcraft_forms::detect::Kind::{CheckBox, Text}; + for scan in [Scan::Straight, Scan::Sideways, Scan::Noisy, Scan::Skewed] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("scan.pdf", None, scanned_form(scan), None).unwrap(); + assert_eq!(page_texts(&s, id), [""], "{scan:?}: a picture has no text"); + let info = s.get(id).unwrap().info.pages[0].clone(); + assert_eq!((info.width.round(), info.height.round()), (420.0, 300.0), "{scan:?}"); + // Where a field shows on the page, in points from the bottom-left as displayed. + let shown = |r: [f64; 4]| { + let a = info.user_to_view(r[0] as f32, r[1] as f32); + let b = info.user_to_view(r[2] as f32, r[3] as f32); + [a[0].min(b[0]), 300.0 - a[1].max(b[1]), a[0].max(b[0]), 300.0 - a[1].min(b[1])].map(f64::from) + }; + let found = s.detect_fields(id, &[]); + let got: Vec<(pdfcraft_forms::detect::Kind, [f64; 4])> = found.iter().map(|(_, c)| (c.kind.clone(), shown(c.rect))).collect(); + // In reading order: the rule after "Name:", the box below "Anschrift", the "Telefon" + // box, two check boxes. + let want = [ + (Text, [80.0, 229.0, 380.0, 245.0]), + (Text, [31.0, 151.0, 379.0, 189.0]), + (Text, [31.0, 91.0, 379.0, 119.0]), + (CheckBox, [30.0, 50.0, 40.0, 60.0]), + (CheckBox, [150.0, 50.0, 160.0, 60.0]), + ]; + assert_eq!(got.len(), want.len(), "{scan:?}: {got:?}"); + for ((kind, r), (want_kind, w)) in got.iter().zip(&want) { + assert_eq!(kind, want_kind, "{scan:?}: {got:?}"); + // The labelled box's top is wherever the label ends; everything else is close. A + // degree askew, a 350 pt line climbs 6 pt, and the field stays inside the box. + let tol = if matches!(scan, Scan::Skewed) { [6.5; 4] } else { [2.5, 2.5, 2.5, if w[3] == 189.0 { 6.0 } else { 2.5 }] }; + assert!(r.iter().zip(w).zip(tol).all(|((a, b), t)| (a - b).abs() <= t), "{scan:?}: {r:?}, want {w:?}"); + } + let names: Vec<&str> = found.iter().map(|(_, c)| c.name.as_str()).collect(); + if ocr::available() { + // (Askew, recognition may read "Haupt" and "Zweig" as one phrase.) + assert!(names.contains(&"Name") && names.contains(&"Telefon") && names.iter().any(|n| n.starts_with("Haupt")), "{scan:?}: {names:?}"); + } else { + // Without text recognition the fields get the default names. + assert!(names.iter().all(|n| n.starts_with("Text") || n.starts_with("Check Box")), "{scan:?}: {names:?}"); + } + + // Added as one step, where they were found; on the turned page each field turns with + // it, so typing reads upright. + let added = s.auto_detect_fields(id, &[]).unwrap(); + assert_eq!(added.len(), want.len()); + let d = s.get(id).unwrap(); + assert_eq!(d.can_undo(), Some("Detect form fields")); + let turn = if matches!(scan, Scan::Sideways) { 90 } else { 0 }; + for (_, c) in &found { + let f = d.form.iter().find(|f| f.name == c.name).unwrap(); + assert_eq!(f.widgets[0].rotation, turn, "{scan:?}: {}", f.name); + let r = f.widgets[0].rect; + assert!(r.iter().zip(c.rect).all(|(a, b)| (a - b).abs() < 0.01), "{scan:?}: {r:?} moved from {:?}", c.rect); + } + assert!(s.detect_fields(id, &[]).is_empty(), "{scan:?}: nothing left to detect"); + } +} + #[test] fn comparing_two_versions_of_a_document() { let mut s = Session::new().with_clock(|| 1_700_000_000); diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index 685a818a9..dbee788e3 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -3,11 +3,13 @@ //! field from the label next to it (Acrobat's auto-naming). //! //! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads -//! the shapes from a page's content. +//! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan). use pdfcraft_content::{Matrix, parse}; use pdfcraft_cos::{Document, Object}; +pub mod raster; + /// A piece of text on the page, in user space. #[derive(Clone, Debug, PartialEq)] pub struct Word { diff --git a/crates/forms/src/detect/raster.rs b/crates/forms/src/detect/raster.rs new file mode 100644 index 000000000..d54e36e5c --- /dev/null +++ b/crates/forms/src/detect/raster.rs @@ -0,0 +1,550 @@ +//! Field detection on scanned pages: the [`Shapes`] that [`detect`](super::detect) needs, read +//! from a picture of the page instead of its content stream. +//! +//! Steps: +//! - Ink is a pixel clearly darker than the paper around it (a local mean, so the uneven paper +//! of a scan or photo still reads as paper). +//! - Straight lines are runs of ink at least [`MIN_LINE`] long, horizontal or vertical, with +//! short gaps (noise, a faint print) bridged. +//! - A box is a patch of paper that the lines enclose and that is close to a rectangle. Its +//! writing area is the whole box when it is empty, or else the largest band of empty rows or +//! columns in it (many official forms print a small label in the corner of each box). +//! - The remaining horizontal lines, the ones that are not the edge of a box, are rules. +//! +//! Everything is bounded: pictures larger than [`MAX_PIXELS`] are refused (the caller renders +//! at a resolution that fits), and the results are capped. + +use super::Shapes; + +/// The largest picture analysed (about A3 at 200 dpi). +pub const MAX_PIXELS: usize = 12_000_000; +/// The shortest run of ink that counts as a line, in points (a check box side is at least 5). +const MIN_LINE: f64 = 4.0; +/// Gaps up to this long (points) inside a line are bridged. +const GAP: f64 = 1.0; +/// Lines thicker than this (points, on average) are dark areas, not rules. +const MAX_THICKNESS: f64 = 3.0; +/// The shortest rule reported, in points ([`detect`](super::detect) wants 40 for a field). +const MIN_RULE: f64 = 20.0; +/// A box is at least this share of its bounding rectangle (a skewed scan loses a little). +const RECTANGULAR: f64 = 0.75; +/// Boxes taller than this (points) are frames around parts of the form, not places to write. +const MAX_BOX_HEIGHT: f64 = 120.0; +/// At most this many boxes and rules are reported. +const MAX_SHAPES: usize = 4000; + +/// A grey picture (0 black, 255 white) and its size. +struct Grey { + w: usize, + h: usize, + v: Vec, +} + +/// The shapes in `rgba` (premultiplied RGBA8, `width` × `height`, the whole page as displayed), +/// where `px_per_pt` pixels make a point. Coordinates are points with the origin at the +/// picture's bottom-left and y up, the frame [`detect`](super::detect) expects. Empty when the +/// picture is malformed or larger than [`MAX_PIXELS`]. +pub fn shapes(rgba: &[u8], width: u32, height: u32, px_per_pt: f64) -> Shapes { + let (w, h) = (width as usize, height as usize); + let valid = w >= 8 + && h >= 8 + && px_per_pt.is_finite() + && (0.25..=20.0).contains(&px_per_pt) + && w.checked_mul(h).is_some_and(|n| n <= MAX_PIXELS) + && w.checked_mul(h).and_then(|n| n.checked_mul(4)) == Some(rgba.len()); + if !valid { + return Shapes::default(); + } + let grey = grey(rgba, w, h); + let ink = ink(&grey, px_per_pt); + let px = |pt: f64| (pt * px_per_pt).round().max(1.0) as usize; + let (min_line, gap) = (px(MIN_LINE), px(GAP)); + let horizontal = runs(&ink, w, h, min_line, gap, true); + let vertical = runs(&ink, w, h, min_line, gap, false); + // The lines, a pixel thicker so that a hairline crack in a corner still encloses. + let mut lines: Vec = horizontal.iter().zip(&vertical).map(|(a, b)| *a || *b).collect(); + lines = dilate(&lines, w, h); + let areas = enclosed(&lines, &ink, w, h, px_per_pt); + let to_pt = |x: usize, y: usize| [x as f64 / px_per_pt, (h as f64 - y as f64) / px_per_pt]; + let mut out = Shapes::default(); + for [x0, y0, x1, y1] in areas.into_iter().take(MAX_SHAPES) { + let (a, b) = (to_pt(x0, y1), to_pt(x1, y0)); + out.boxes.push([a[0], a[1], b[0], b[1]]); + } + let tol = px(2.0); + // Whether a vertical line passes the end of a horizontal one at column `x`. + let attached = |x: usize, l: &Component| { + (l.y0.saturating_sub(tol)..l.y1.saturating_add(tol).min(h)) + .any(|y| (x.saturating_sub(tol)..x.saturating_add(tol + 1).min(w)).any(|x| vertical.get(y * w + x).copied().unwrap_or(false))) + }; + for l in components(&horizontal, w, h) { + let len = l.x1 - l.x0; + let thick = l.count as f64 / len.max(1) as f64; + if (len as f64) < MIN_RULE * px_per_pt || thick > MAX_THICKNESS * px_per_pt { + continue; + } + let y = l.y_sum as f64 / l.count.max(1) as f64; + // The scan's own border is not a line on the form. + if y < tol as f64 || y > h.saturating_sub(tol) as f64 { + continue; + } + // Closed at both ends: the edge of a box, a table or a frame, not a line to write on. + if attached(l.x0, &l) && attached(l.x1 - 1, &l) { + continue; + } + let y = (h as f64 - y) / px_per_pt; + if out.rules.len() < MAX_SHAPES { + out.rules.push([l.x0 as f64 / px_per_pt, y, l.x1 as f64 / px_per_pt, y]); + } + } + out +} + +/// Luminance over white paper (a transparent pixel is paper). +fn grey(rgba: &[u8], w: usize, h: usize) -> Grey { + let v = rgba + .as_chunks::<4>() + .0 + .iter() + .map(|&[r, g, b, a]| { + let (r, g, b, a) = (u32::from(r), u32::from(g), u32::from(b), u32::from(a)); + // Premultiplied over white: c + (255 - a). + let l = (r * 77 + g * 150 + b * 29) >> 8; + (l + (255 - a)).min(255) as u8 + }) + .collect(); + Grey { w, h, v } +} + +/// Which pixels are ink: darker than 80 % of the mean around them (a window of about 20 points), +/// or dark outright. +fn ink(g: &Grey, px_per_pt: f64) -> Vec { + let (w, h) = (g.w, g.h); + let r = ((10.0 * px_per_pt).round() as usize).clamp(2, 200); + // A separable box mean: rows first, then columns. Sums stay below 255 × 401 × 401 < 2^32. + let mut rows = vec![0u32; w * h]; + for y in 0..h { + let line = g.v.get(y * w..(y + 1) * w).unwrap_or_default(); + let mut sum: u32 = line.iter().take(r + 1).map(|v| u32::from(*v)).sum(); + let mut n = (r + 1).min(w) as u32; + for x in 0..w { + if let Some(slot) = rows.get_mut(y * w + x) { + *slot = sum / n.max(1); + } + if let Some(v) = line.get(x + r + 1) { + sum += u32::from(*v); + n += 1; + } + if x >= r + && let Some(v) = line.get(x - r) + { + sum = sum.saturating_sub(u32::from(*v)); + n = n.saturating_sub(1); + } + } + } + let mut out = vec![false; w * h]; + for x in 0..w { + let at = |y: usize| rows.get(y * w + x).copied().unwrap_or(255); + let mut sum: u32 = (0..=r.min(h - 1)).map(at).sum(); + let mut n = (r + 1).min(h) as u32; + for y in 0..h { + let mean = sum / n.max(1); + let i = y * w + x; + let v = u32::from(g.v.get(i).copied().unwrap_or(255)); + if let Some(slot) = out.get_mut(i) { + *slot = v < 96 || (v * 5 < mean * 4 && v < 224); + } + if y + r + 1 < h { + sum += at(y + r + 1); + n += 1; + } + if y >= r { + sum = sum.saturating_sub(at(y - r)); + n = n.saturating_sub(1); + } + } + } + out +} + +/// The pixels on straight runs of ink at least `min` long, along rows (`across`) or columns, +/// with gaps up to `gap` bridged. A run is nearly solid: a word in small print, its letters +/// run together by noise, has more gaps than a line. +fn runs(ink: &[bool], w: usize, h: usize, min: usize, gap: usize, across: bool) -> Vec { + let mut out = vec![false; w * h]; + let (outer, inner) = if across { (h, w) } else { (w, h) }; + let index = |o: usize, i: usize| if across { o * w + i } else { i * w + o }; + for o in 0..outer { + // The current run: its first and last inked position and how many are inked. + let mut run: Option<(usize, usize, usize)> = None; + let close = |run: Option<(usize, usize, usize)>, out: &mut Vec| { + if let Some((a, b, n)) = run + && b + 1 - a >= min + && n * 4 >= (b + 1 - a) * 3 + { + for i in a..=b { + if let Some(slot) = out.get_mut(index(o, i)) { + *slot = true; + } + } + } + }; + for i in 0..inner { + if !ink.get(index(o, i)).copied().unwrap_or(false) { + continue; + } + run = match run { + Some((a, b, n)) if i - b <= gap + 1 => Some((a, i, n + 1)), + other => { + close(other, &mut out); + Some((i, i, 1)) + } + }; + } + close(run, &mut out); + } + out +} + +/// `mask` grown by one pixel in each of the four directions. +fn dilate(mask: &[bool], w: usize, h: usize) -> Vec { + let at = |x: usize, y: usize| mask.get(y * w + x).copied().unwrap_or(false); + let mut out = vec![false; w * h]; + for y in 0..h { + for x in 0..w { + let on = at(x, y) || (x > 0 && at(x - 1, y)) || (x + 1 < w && at(x + 1, y)) || (y > 0 && at(x, y - 1)) || (y + 1 < h && at(x, y + 1)); + if let Some(slot) = out.get_mut(y * w + x) { + *slot = on; + } + } + } + out +} + +/// Where to write in the rectangular patches of paper that `lines` enclose (none that touch +/// the picture's edge), in pixels: [x0, y0, x1, y1) with y down. +fn enclosed(lines: &[bool], ink: &[bool], w: usize, h: usize, px_per_pt: f64) -> Vec<[usize; 4]> { + let mut label = vec![0u32; w * h]; + let mut out = Vec::new(); + let mut stack: Vec = Vec::new(); + let mut next: u32 = 0; + let max_h = (MAX_BOX_HEIGHT * px_per_pt) as usize; + for start in 0..w * h { + if lines.get(start).copied().unwrap_or(true) || label.get(start).copied().unwrap_or(1) != 0 { + continue; + } + next = next.saturating_add(1); + if next == u32::MAX { + break; + } + let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0, 0); + let mut count = 0usize; + let mut border = false; + stack.clear(); + stack.push(start); + if let Some(l) = label.get_mut(start) { + *l = next; + } + while let Some(i) = stack.pop() { + let (x, y) = (i % w, i / w); + count += 1; + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)); + border |= x == 0 || y == 0 || x + 1 == w || y + 1 == h; + let mut visit = |j: usize| { + if !lines.get(j).copied().unwrap_or(true) + && let Some(l) = label.get_mut(j) + && *l == 0 + { + *l = next; + stack.push(j); + } + }; + if x > 0 { + visit(i - 1); + } + if x + 1 < w { + visit(i + 1); + } + if y > 0 { + visit(i - w); + } + if y + 1 < h { + visit(i + w); + } + } + let (bw, bh) = (x1.saturating_sub(x0), y1.saturating_sub(y0)); + let min = (MIN_LINE * px_per_pt) as usize; + if border || bw < min || bh < min || bh > max_h || (count as f64) < RECTANGULAR * (bw * bh) as f64 { + continue; + } + if let Some(area) = writing_area(&label, ink, [w, h], [x0, y0, x1, y1], next, px_per_pt) { + out.push(area); + } + if out.len() >= MAX_SHAPES { + break; + } + } + out +} + +/// Where to write in the region `id` with bounding rectangle `rect`, in pixels: all of it when it is empty, +/// else the largest band of empty rows big enough for a line of handwriting (below or above a +/// label), else the largest such band of columns (beside one). A small square (a check box) +/// with ink in it is already ticked or is a letter, not a blank. Ink counts anywhere in the +/// rectangle except inside other regions: the strokes of a label are lines of their own. +/// +/// A side of the area that is a side of the region reaches out to the middle of the line around +/// it (the region stops short of it by the dilation and half a line); a side that faces a label +/// keeps a point away from it. +fn writing_area(label: &[u32], ink: &[bool], [w, h]: [usize; 2], rect: [usize; 4], id: u32, px_per_pt: f64) -> Option<[usize; 4]> { + let [x0, y0, x1, y1] = rect; + let px = |pt: f64| (pt * px_per_pt).round().max(1.0) as usize; + let (pad, gap) = (1 + px(0.5), px(1.0)); + // `area` within `rect`: its sides on the region's sides padded, the others moved in. + let place = |a: [usize; 4]| { + let lo = |v: usize, edge: usize| if v == edge { v.saturating_sub(pad) } else { v + gap }; + let hi = |v: usize, edge: usize, max: usize| if v == edge { v.saturating_add(pad).min(max) } else { v.saturating_sub(gap) }; + let out = [lo(a[0], x0), lo(a[1], y0), hi(a[2], x1, w), hi(a[3], y1, h)]; + (out[0] < out[2] && out[1] < out[3]).then_some(out) + }; + let (bw, bh) = (x1 - x0, y1 - y0); + let mut rows = vec![0usize; bh]; + let mut cols = vec![0usize; bw]; + for y in y0..y1 { + for x in x0..x1 { + let i = y * w + x; + let mine = matches!(label.get(i), Some(&l) if l == id || l == 0); + if mine && ink.get(i).copied().unwrap_or(false) { + if let Some(r) = rows.get_mut(y - y0) { + *r += 1; + } + if let Some(c) = cols.get_mut(x - x0) { + *c += 1; + } + } + } + } + // A few specks per row are noise, not print. + let (row_noise, col_noise) = ((bw / 200).max(1), (bh / 200).max(1)); + if rows.iter().all(|n| *n <= row_noise) { + return place(rect); + } + if bw as f64 <= 24.0 * px_per_pt && bh as f64 <= 24.0 * px_per_pt { + return None; + } + let longest = |counts: &[usize], allowed: usize| { + let (mut best, mut start) = ((0, 0), None); + for (i, c) in counts.iter().chain(std::iter::once(&usize::MAX)).enumerate() { + match (start, *c <= allowed) { + (None, true) => start = Some(i), + (Some(s), false) => { + if i - s > best.1 - best.0 { + best = (s, i); + } + start = None; + } + _ => {} + } + } + best + }; + let (r0, r1) = longest(&rows, row_noise); + let (c0, c1) = longest(&cols, col_noise); + let tall = |n: usize| n as f64 >= 12.0 * px_per_pt; + let wide = |n: usize| n as f64 >= 40.0 * px_per_pt; + let band = (tall(r1 - r0) && wide(bw)).then_some([x0, y0 + r0, x1, y0 + r1]); + let column = (wide(c1 - c0) && tall(bh)).then_some([x0 + c0, y0, x0 + c1, y1]); + band.or(column).and_then(place) +} + +/// A connected piece of a line mask. +struct Component { + x0: usize, + x1: usize, + y0: usize, + y1: usize, + count: usize, + /// The sum of the rows of its pixels (u64: on 32-bit targets it outgrows `usize`). + y_sum: u64, +} + +/// The 8-connected pieces of `mask`. +fn components(mask: &[bool], w: usize, h: usize) -> Vec { + let mut seen = vec![false; w * h]; + let mut out = Vec::new(); + let mut stack: Vec = Vec::new(); + for start in 0..w * h { + if !mask.get(start).copied().unwrap_or(false) || seen.get(start).copied().unwrap_or(true) { + continue; + } + let mut c = Component { x0: usize::MAX, x1: 0, y0: usize::MAX, y1: 0, count: 0, y_sum: 0 }; + stack.clear(); + stack.push(start); + if let Some(s) = seen.get_mut(start) { + *s = true; + } + while let Some(i) = stack.pop() { + let (x, y) = (i % w, i / w); + c.x0 = c.x0.min(x); + c.x1 = c.x1.max(x + 1); + c.y0 = c.y0.min(y); + c.y1 = c.y1.max(y + 1); + c.count += 1; + c.y_sum = c.y_sum.saturating_add(y as u64); + for dy in [-1isize, 0, 1] { + for dx in [-1isize, 0, 1] { + let (Some(nx), Some(ny)) = (x.checked_add_signed(dx), y.checked_add_signed(dy)) else { continue }; + if nx >= w || ny >= h { + continue; + } + let j = ny * w + nx; + if mask.get(j).copied().unwrap_or(false) + && let Some(s) = seen.get_mut(j) + && !*s + { + *s = true; + stack.push(j); + } + } + } + } + out.push(c); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A white page `w` × `h` pixels to draw a form on, two pixels to the point. + struct Page { + w: usize, + h: usize, + px: Vec, + } + + const S: f64 = 2.0; + + impl Page { + fn new(w: usize, h: usize) -> Self { + Page { w, h, px: vec![255; w * h * 4] } + } + fn fill(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, v: u8) { + for y in y0..y1.min(self.h) { + for x in x0..x1.min(self.w) { + let i = (y * self.w + x) * 4; + self.px[i..i + 3].copy_from_slice(&[v, v, v]); + } + } + } + /// An outlined box, `t` pixels thick. + fn frame(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, t: usize) { + self.fill(x0, y0, x1, y0 + t, 0); + self.fill(x0, y1 - t, x1, y1, 0); + self.fill(x0, y0, x0 + t, y1, 0); + self.fill(x1 - t, y0, x1, y1, 0); + } + /// Something like a word: short strokes, none a line. + fn word(&mut self, x: usize, y: usize, letters: usize) { + for i in 0..letters { + self.fill(x + i * 12, y, x + i * 12 + 2, y + 14, 30); + self.fill(x + i * 12 + 4, y + 6, x + i * 12 + 9, y + 8, 30); + } + } + fn shapes(&self) -> Shapes { + shapes(&self.px, self.w as u32, self.h as u32, S) + } + } + + /// Points (y up) of a pixel rectangle on a page `h` pixels high. + fn pt(h: usize, [x0, y0, x1, y1]: [usize; 4]) -> [f64; 4] { + [x0 as f64 / S, (h - y1) as f64 / S, x1 as f64 / S, (h - y0) as f64 / S] + } + + fn near(a: [f64; 4], b: [f64; 4], tol: f64) -> bool { + a.iter().zip(b).all(|(p, q)| (p - q).abs() <= tol) + } + + #[test] + fn a_scanned_form_gives_its_rules_boxes_and_check_boxes() { + let mut p = Page::new(600, 400); + // A label and the rule to write on after it. + p.word(20, 40, 4); + p.fill(80, 56, 400, 58, 0); + // An empty box, and a box with a small label in its top-left corner. + p.frame(20, 100, 420, 150, 2); + p.frame(20, 180, 420, 260, 2); + p.word(26, 186, 3); + // A check box (10 pt) with a label to its right. + p.frame(20, 300, 40, 320, 2); + p.word(50, 303, 5); + let s = p.shapes(); + assert_eq!(s.rules.len(), 1, "{s:?}"); + let r = s.rules[0]; + assert!((r[0] - 40.0).abs() < 1.5 && (r[2] - 200.0).abs() < 1.5 && (r[1] - (400.0 - 57.0) / S).abs() < 1.0, "{r:?}"); + assert_eq!(s.boxes.len(), 3, "{s:?}"); + // The empty box is the inside of its frame (give or take the line). + assert!(s.boxes.iter().any(|b| near(*b, pt(400, [20, 100, 420, 150]), 2.0)), "{s:?}"); + // The labelled box: only the part below the label. + let lower = s.boxes.iter().find(|b| (b[1] - (400.0 - 260.0) / S).abs() < 2.0).expect("labelled box"); + assert!(lower[3] <= (400.0 - 200.0) / S + 2.0 && lower[3] - lower[1] >= 12.0, "{lower:?}"); + // The check box is square and about 10 pt. + assert!(s.boxes.iter().any(|b| near(*b, pt(400, [20, 300, 40, 320]), 1.5)), "{s:?}"); + } + + #[test] + fn text_and_a_frame_around_the_page_are_not_shapes() { + let mut p = Page::new(400, 300); + for line in 0..6 { + p.word(30, 30 + line * 30, 20); + } + // A frame around the whole page, and a solid dark bar (a logo, a censored part). + p.frame(4, 4, 396, 296, 2); + p.fill(40, 240, 360, 270, 0); + let s = p.shapes(); + assert!(s.boxes.is_empty() && s.rules.is_empty(), "{s:?}"); + } + + #[test] + fn noise_and_uneven_paper_do_not_hide_the_lines() { + let mut p = Page::new(600, 300); + p.fill(80, 56, 400, 58, 0); + p.frame(20, 100, 420, 150, 2); + p.frame(20, 200, 40, 220, 2); + // Paper that darkens to the right (a shadow) and speckled with deterministic noise; the + // lines lose some pixels. + let mut seed: u32 = 12345; + for y in 0..p.h { + for x in 0..p.w { + seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345); + let noise = (seed >> 16) % 41; + let i = (y * p.w + x) * 4; + let v = p.px[i]; + let shade = (x * 60 / p.w) as u8; + let v = if v == 0 { if noise < 3 { 200 } else { 40 + noise as u8 } } else { 255u8.saturating_sub(shade).saturating_sub(noise as u8) }; + p.px[i..i + 3].copy_from_slice(&[v, v, v]); + } + } + let s = p.shapes(); + assert_eq!(s.rules.len(), 1, "{s:?}"); + assert_eq!(s.boxes.len(), 2, "{s:?}"); + } + + #[test] + fn malformed_pictures_give_nothing() { + let white = vec![255u8; 64 * 64 * 4]; + assert_eq!(shapes(&white, 64, 64, 2.0), Shapes::default()); + // Size and data disagree, sizes overflow, or the scale is nonsense. + assert_eq!(shapes(&white, 64, 65, 2.0), Shapes::default()); + assert_eq!(shapes(&white, u32::MAX, u32::MAX, 2.0), Shapes::default()); + assert_eq!(shapes(&[], 0, 0, 2.0), Shapes::default()); + for s in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e9] { + assert_eq!(shapes(&white, 64, 64, s), Shapes::default(), "{s}"); + } + // All black: one dark area, no lines. + let black: Vec = [0u8, 0, 0, 255].repeat(64 * 64); + assert_eq!(shapes(&black, 64, 64, 2.0), Shapes::default()); + } +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bf39b83f8..a7c5f8300 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -5064,8 +5064,8 @@ milestone = "M6" status = "partial" commands = ["form.detect", "form.prepare"] tools = ["form_detect_fields"] -evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] -notes = "Underscore runs, rules, empty boxes and small squares (check boxes); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Not yet: comb boxes, tables of cells, scanned forms (needs OCR first), radio groups." +evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/engine/src/tests.rs::detecting_fields_on_a_scanned_form", "crates/forms/src/detect/raster.rs::a_scanned_form_gives_its_rules_boxes_and_check_boxes", "crates/forms/src/detect/raster.rs::noise_and_uneven_paper_do_not_hide_the_lines", "crates/automation/tests/automation.rs::detecting_form_fields_through_tools", "crates/ui-egui/tests/javascript.rs::prepare_a_form_detects_fields_on_a_paper_form", "crates/ui-egui/tests/prepare.rs::deleting_several_selected_fields_and_detecting_fields_from_the_panel"] +notes = "Underscore runs, rules, empty boxes and small squares (check boxes); runs when Prepare a form starts on a document without fields, and from Prepare a form ▸ Detect form fields at any time (existing fields are skipped). Scanned pages (an image covers at least half the page and the content draws no lines) are rendered and their rules, boxes and check boxes read from the pixels; a box with a small printed label gets a field in its empty part. Tested on synthetic scans only: straight, stored sideways with /Rotate 90 (fields get /MK /R so typing reads upright), noisy, and 1 degree askew (fields stay inside their boxes but lose up to the skew in height). Labels come from the text layer or, when the OCR models are installed, from text recognition (Latin without accents, so German labels with umlauts make poorer names); without either, fields get default names (Text1, Check Box1). Recognition runs synchronously, about a second per scanned page. Not yet: comb boxes, dotted leader lines, tables of cells as such, deskewing, radio groups." [[feature]] id = "form.auto-name-fields"