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 09:50:00 +02:00
committed by GitHub
parent eeadc79d15
commit 50f261d70c
8 changed files with 194 additions and 23 deletions
+9 -4
View File
@@ -918,11 +918,16 @@ impl Automation {
let list: Vec<Value> = found
.iter()
.map(|(p, c)| {
let kind = match c.kind {
pdfcraft_engine::detect::Kind::Text => "text",
pdfcraft_engine::detect::Kind::CheckBox => "checkbox",
let (kind, comb) = match c.kind {
pdfcraft_engine::detect::Kind::Text => ("text", None),
pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None),
pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)),
};
json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) })
let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) });
if let Some(cells) = comb {
v["comb"] = json!(cells);
}
v
})
.collect();
if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() {
+1 -1
View File
@@ -1101,7 +1101,7 @@ pub fn tools() -> Vec<ToolDef> {
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; 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).")
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, rows of comb 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); a row of comb boxes is one text field with comb set to its number of cells, created with the comb flag and that character limit.")
.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).")
+35
View File
@@ -4992,6 +4992,41 @@ fn detecting_form_fields_through_tools() {
assert_eq!(f["fields"][1]["value"], "Lisbon", "{f}");
}
#[test]
fn detecting_comb_boxes_through_tools() {
let mut body = String::from("BT /F1 10 Tf 40 105 Td (Postcode) Tj ET\n");
for i in 0..5 {
body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16));
}
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let dir = workdir("detect-comb");
std::fs::write(dir.join("comb.pdf"), pdf).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "comb.pdf" }))["doc"].as_u64().unwrap();
let r = ok(&mut a, "form_detect_fields", json!({ "doc": doc }));
assert_eq!(r["fields"], json!([{ "page": 1, "kind": "text", "comb": 5, "name": "Postcode", "rect": [120.0, 100.0, 200.0, 120.0] }]), "{r}");
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Postcode": "75001" } }));
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "75001");
}
#[test]
fn comparing_documents_through_tools() {
let dir = workdir("compare");
+18 -13
View File
@@ -6,7 +6,7 @@
//! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it
//! off, scripts don't run and AF calls keep working natively.
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, ScriptResult, Scripts};
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
pub use pdfcraft_js::{Limits, Outcome, Request};
use crate::{DocId, Edit, EditError, Session};
@@ -343,19 +343,24 @@ impl Session {
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: tall > 30.0 },
pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox,
},
name: Some(c.name.clone()),
});
if let Some(props) = props {
let (kind, comb) = match c.kind {
pdfcraft_forms::detect::Kind::Text => (pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, None),
pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None),
pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)),
};
let mut props = FieldProps::default();
if turn != 0 {
// A turned field keeps its place: the rectangle is already the one on the page.
props.rotation = Some((0, turn));
props.rect = Some((0, c.rect));
}
if let Some(cells) = comb {
props.max_len = Some(Some(cells));
props.flags = vec![(pdfcraft_forms::flags::COMB, true)];
}
edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) });
if turn != 0 || comb.is_some() {
edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) });
}
}
+28
View File
@@ -3691,6 +3691,34 @@ fn detecting_fields_on_a_scanned_form() {
}
}
#[test]
fn detecting_comb_boxes_adds_one_comb_field_per_row() {
// The rows from #1887: five 18 x 22 cells and ten 16 x 20 cells, each a stroked `re`.
let mut body = String::from("BT /F1 10 Tf 40 205 Td (Postcode A) Tj ET BT /F1 10 Tf 40 105 Td (Postcode B) Tj ET\n");
for i in 0..5 {
body.push_str(&format!("{} 200 18 22 re S\n", 120 + i * 18));
}
for i in 0..10 {
body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16));
}
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("comb.pdf", None, Arc::new(one_line_page("/Font << /F1 4 0 R >>", &body)), None).unwrap();
let found: Vec<(String, pdfcraft_forms::detect::Kind)> = s.detect_fields(id, &[]).into_iter().map(|(_, c)| (c.name, c.kind)).collect();
assert_eq!(
found,
[
("Postcode A".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 5 }),
("Postcode B".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 10 })
]
);
s.auto_detect_fields(id, &[]).unwrap();
let d = s.get(id).unwrap();
let combs: Vec<(&str, FormFieldKind, bool, Option<usize>)> =
d.form.iter().map(|f| (f.name.as_str(), f.kind, f.has(pdfcraft_forms::flags::COMB), f.max_len)).collect();
assert_eq!(combs, [("Postcode A", FormFieldKind::Text, true, Some(5)), ("Postcode B", FormFieldKind::Text, true, Some(10))]);
assert_eq!(d.can_undo(), Some("Detect form fields"));
}
#[test]
fn comparing_two_versions_of_a_document() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
+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();
+2 -2
View File
@@ -5088,8 +5088,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/forms/src/tests.rs::page_shapes_reads_rectangles_with_points_along_their_sides", "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); a box drawn with points along its sides (a midpoint on each vertical side, as the French 2042 form's cells) is read as the rectangle it is (#2942); 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."
evidence = ["crates/forms/src/tests.rs::detection_finds_blanks_rules_boxes_and_names_them", "crates/forms/src/tests.rs::page_shapes_reads_rectangles_with_points_along_their_sides", "crates/engine/src/tests.rs::detecting_fields_on_a_printed_form", "crates/forms/src/tests.rs::detection_makes_a_row_of_touching_cells_one_comb_field", "crates/engine/src/tests.rs::detecting_comb_boxes_adds_one_comb_field_per_row", "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/automation/tests/automation.rs::detecting_comb_boxes_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, small squares (check boxes) and rows of two or more touching cells of one size (one comb text field, character limit = cells; tested on drawn forms only); a box drawn with points along its sides (a midpoint on each vertical side, as the French 2042 form's cells) is read as the rectangle it is (#2942); 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: dotted leader lines, tables of cells as such, deskewing, radio groups."
[[feature]]
id = "form.auto-name-fields"