M6: detect a row of comb boxes as one comb text field (#2111)

* M6: detect a row of comb boxes as one comb text field (fixes #1887)

* M6: keep empty tables of wide cells out of comb detection (#1887)

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Marco Arnulfo
2026-10-18 03:50:00 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent eeadc79d15
commit 50f261d70c
8 changed files with 194 additions and 23 deletions
+53 -3
View File
@@ -1,6 +1,6 @@
//! Prepare a form ▸ automatic field detection: find where a printed form expects answers —
//! underscore runs, rules (horizontal lines), empty boxes and small squares — and name each new
//! field from the label next to it (Acrobat's auto-naming).
//! underscore runs, rules (horizontal lines), empty boxes, rows of comb cells and small squares —
//! and name each new 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, [`raster::shapes`] from a picture of the page (a scan).
@@ -30,6 +30,11 @@ pub struct Shapes {
pub enum Kind {
Text,
CheckBox,
/// A row of touching cells, one character each: a text field with the comb flag and
/// `cells` as its character limit.
Comb {
cells: usize,
},
}
#[derive(Clone, Debug, PartialEq)]
@@ -309,6 +314,42 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String {
clean(&on.iter().map(|w| w.text.as_str()).collect::<Vec<_>>().join(" "))
}
/// Rows of two or more touching cells of one size, as (the row's rect, cell count). Two is
/// enough: dates split into day and month groups of two cells. Cells are square or taller
/// than wide; an empty table of wider cells is not a comb.
fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> {
let cells: Vec<[f64; 4]> = boxes
.iter()
.copied()
.filter(|r| {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
(5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2
})
.collect();
// `b` comes right after `a` in a row: same size, its left edge on a's right edge.
let follows = |a: &[f64; 4], b: &[f64; 4]| {
(b[0] - a[2]).abs() <= 1.0 && (b[1] - a[1]).abs() <= 0.5 && (b[3] - a[3]).abs() <= 0.5 && ((b[2] - b[0]) - (a[2] - a[0])).abs() <= 0.5
};
let mut out = Vec::new();
for first in &cells {
if cells.iter().any(|a| follows(a, first)) {
continue;
}
let (mut last, mut n) = (first, 1usize);
// Each step moves at least 4 pt right, so this ends; the bound is for safety.
while let Some(b) = cells.iter().find(|b| follows(last, b))
&& n < cells.len()
{
last = b;
n += 1;
}
if n >= 2 {
out.push(([first[0], first[1], last[2], first[3]], n));
}
}
out
}
/// Propose fields for a page. `existing` are the page's widget rectangles (no candidate
/// overlaps them); names are unique against `taken`.
pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[String]) -> Vec<Candidate> {
@@ -329,6 +370,15 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S
let l = label_for(&labels, rect, false);
push(Kind::Text, rect, l, &mut out);
}
// Comb boxes (postcodes, dates, IBANs): one text field per row of cells, found before
// check boxes so the cells don't each become one.
for (rect, cells) in combs(&shapes.boxes) {
if labels.iter().any(|x| overlaps(x.rect, rect)) {
continue;
}
let l = label_for(&labels, rect, false);
push(Kind::Comb { cells }, rect, l, &mut out);
}
// Small squares: check boxes, labelled by the text to their right.
for r in &shapes.boxes {
let (w, h) = (r[2] - r[0], r[3] - r[1]);
@@ -381,7 +431,7 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S
for c in &mut out {
let base = if c.name.is_empty() {
match c.kind {
Kind::Text => "Text".to_string(),
Kind::Text | Kind::Comb { .. } => "Text".to_string(),
Kind::CheckBox => "Check Box".to_string(),
}
} else {
+48
View File
@@ -1411,6 +1411,54 @@ fn detection_finds_blanks_rules_boxes_and_names_them() {
assert!(again.is_empty(), "{again:?}");
}
#[test]
fn detection_makes_a_row_of_touching_cells_one_comb_field() {
use crate::detect::*;
let w = |t: &str, x0: f64, y0: f64, x1: f64| Word { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] };
let words = vec![
w("Postcode", 40.0, 205.0, 85.0),
w("A", 88.0, 205.0, 95.0),
w("Postcode", 40.0, 105.0, 85.0),
w("B", 88.0, 105.0, 95.0),
w("Yes", 70.0, 41.0, 90.0),
];
let row = |n: usize, x: f64, y: f64, cw: f64, ch: f64| (0..n).map(move |i| [x + i as f64 * cw, y, x + (i + 1) as f64 * cw, y + ch]);
// 18 x 22 cells (too tall for a check box), 16 x 20 cells (each one check-box sized) drawn
// right to left, and a lone square.
let mut boxes: Vec<[f64; 4]> = row(5, 120.0, 200.0, 18.0, 22.0).collect();
boxes.extend(row(10, 120.0, 100.0, 16.0, 20.0).rev());
boxes.push([50.0, 40.0, 62.0, 52.0]);
let found = detect(&words, &Shapes { boxes, rules: Vec::new() }, &[], &[]);
let got: Vec<(&str, &Kind, [f64; 4])> = found.iter().map(|c| (c.name.as_str(), &c.kind, c.rect)).collect();
assert_eq!(
got,
[
("Postcode A", &Kind::Comb { cells: 5 }, [120.0, 200.0, 210.0, 222.0]),
("Postcode B", &Kind::Comb { cells: 10 }, [120.0, 100.0, 280.0, 120.0]),
("Yes", &Kind::CheckBox, [50.0, 40.0, 62.0, 52.0]),
],
"{found:?}"
);
// Squares with a gap between them stay check boxes, and so do touching squares of
// different sizes.
let apart = Shapes {
boxes: vec![[50.0, 40.0, 62.0, 52.0], [66.0, 40.0, 78.0, 52.0], [80.0, 40.0, 92.0, 52.0], [92.0, 40.0, 102.0, 52.0]],
rules: Vec::new(),
};
let kinds: Vec<Kind> = detect(&[], &apart, &[], &[]).into_iter().map(|c| c.kind).collect();
assert_eq!(kinds, [Kind::CheckBox, Kind::CheckBox, Kind::CheckBox, Kind::CheckBox]);
// An empty table of cells wider than tall (an attendance sheet) is not a comb.
let grid = |cw: f64, ch: f64| {
(0..3).flat_map(move |r| {
(0..3).map(move |c| [100.0 + c as f64 * cw, 300.0 + r as f64 * ch, 100.0 + (c + 1) as f64 * cw, 300.0 + (r + 1) as f64 * ch])
})
};
for (cw, ch) in [(24.0, 18.0), (28.0, 20.0)] {
let table = detect(&[], &Shapes { boxes: grid(cw, ch).collect(), rules: Vec::new() }, &[], &[]);
assert!(table.is_empty(), "{cw} x {ch}: {table:?}");
}
}
#[test]
fn page_shapes_reads_boxes_and_rules() {
let mut doc = fixture();