mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
Field detection read a path as a rectangle only when it had exactly four corners, and gave up on a subpath once it reached five points. The French income tax form 2042 draws its letter cells with a midpoint on each vertical side (six points), so most of its cells were not found. A subpath now keeps up to 16 points, and a point lying on a straight side between its neighbours is dropped before the four-corner test. A point that turns back along a side is not between its neighbours and stays, so a path that doubles back is still not a box. Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
43f8ddf264
commit
e827a2a17c
@@ -40,6 +40,10 @@ pub struct Candidate {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// The most points a subpath may have and still be read as a rectangle: its four corners plus
|
||||
/// points along its sides (#2942: a cell drawn with a midpoint on each vertical side).
|
||||
const MAX_RECT_POINTS: usize = 16;
|
||||
|
||||
/// The rectangles and horizontal segments a page draws (stroked or filled), in user space.
|
||||
#[derive(Default)]
|
||||
struct DetectionPath {
|
||||
@@ -65,12 +69,33 @@ impl DetectionPath {
|
||||
return None;
|
||||
}
|
||||
let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
|
||||
let mut points = self.points.as_slice();
|
||||
let mut points = self.points.clone();
|
||||
let repeated_end = points.len() > 1 && near(points[0], *points.last()?);
|
||||
if repeated_end {
|
||||
points = &points[..points.len() - 1];
|
||||
points.pop();
|
||||
}
|
||||
if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) {
|
||||
if !(self.closed || implicitly_closed || repeated_end) {
|
||||
return None;
|
||||
}
|
||||
// A point on a straight side, between its neighbours, doesn't change the shape: drop it,
|
||||
// so a cell drawn with a midpoint on a side is the rectangle it looks like (#2942). A
|
||||
// point that turns back along the side is not between its neighbours and stays, so a
|
||||
// path that doubles back is still not a rectangle.
|
||||
let between = |a: f64, m: f64, b: f64| a.min(b) - 1e-6 <= m && m <= a.max(b) + 1e-6;
|
||||
let mut i = 0;
|
||||
while points.len() > 4 && i < points.len() {
|
||||
let n = points.len();
|
||||
let (Some(&a), Some(&m), Some(&b)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { break };
|
||||
let on_row = (a[1] - m[1]).abs() < 1e-6 && (m[1] - b[1]).abs() < 1e-6 && between(a[0], m[0], b[0]);
|
||||
let on_column = (a[0] - m[0]).abs() < 1e-6 && (m[0] - b[0]).abs() < 1e-6 && between(a[1], m[1], b[1]);
|
||||
if on_row || on_column {
|
||||
points.remove(i);
|
||||
i = 0;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if points.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let mut horizontal = [false; 4];
|
||||
@@ -157,7 +182,7 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
|
||||
let (bx, by) = ctm.apply(x, y);
|
||||
let b = [bx, by];
|
||||
if !path.complex {
|
||||
if path.points.len() >= 5 {
|
||||
if path.points.len() > MAX_RECT_POINTS {
|
||||
path.complex = true;
|
||||
} else {
|
||||
if path.points.is_empty() {
|
||||
|
||||
@@ -2023,6 +2023,29 @@ fn page_shapes_accepts_rectangle_closure_and_paint_variants() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2942: a cell drawn with a point in the middle of each vertical side (the French 2042 form's
|
||||
/// letter cells), or on every side, is the rectangle it looks like. A point that turns back
|
||||
/// along a side is not on the way between its neighbours, so such a path is still not a box.
|
||||
#[test]
|
||||
fn page_shapes_reads_rectangles_with_points_along_their_sides() {
|
||||
for content in [
|
||||
"100 100 m 111.1 100 l 111.1 107.35 l 111.1 114.7 l 100 114.7 l 100 107.35 l h f",
|
||||
"100 100 m 105 100 l 111.1 100 l 111.1 107.35 l 111.1 114.7 l 105 114.7 l 100 114.7 l 100 107.35 l h S",
|
||||
"100 107.35 m 100 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h f",
|
||||
] {
|
||||
let shapes = detection_test_shapes(content.as_bytes());
|
||||
assert_eq!(shapes.boxes, [[100.0, 100.0, 111.1, 114.7]], "{content}");
|
||||
}
|
||||
for content in [
|
||||
// Out past the corner and back: a spike, not a side.
|
||||
"100 100 m 130 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h S",
|
||||
// A sixth point off the sides.
|
||||
"100 100 m 111.1 100 l 115 107 l 111.1 114.7 l 100 114.7 l 100 107.35 l h S",
|
||||
] {
|
||||
assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() {
|
||||
for content in [
|
||||
|
||||
@@ -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/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."
|
||||
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."
|
||||
|
||||
[[feature]]
|
||||
id = "form.auto-name-fields"
|
||||
|
||||
Reference in New Issue
Block a user