mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 20:47:32 -06:00
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:
co-authored by
Brandon Thomas
parent
eeadc79d15
commit
50f261d70c
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user