M6: Prepare a form detects fields (underscore blanks, rules, boxes, check squares) and names them from their labels; form.detect, form_detect_fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
620ef4fcad
commit
aa194686f1
|
|
@ -3894,8 +3894,10 @@ name = "printcraft-forms"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"printcraft-content",
|
||||
"printcraft-cos",
|
||||
"printcraft-fonts",
|
||||
"printcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -259,6 +259,26 @@ impl Automation {
|
|||
Ok(json!({ "path": target.to_string_lossy(), "rows": files.len(), "columns": columns }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_detect_fields(&mut self, a: &Args) -> Result<Value> {
|
||||
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
|
||||
let id = self.doc(a)?.id;
|
||||
let found = self.session.detect_fields(id, &pages);
|
||||
let list: Vec<Value> = found
|
||||
.iter()
|
||||
.map(|(p, c)| {
|
||||
let kind = match c.kind {
|
||||
printcraft_engine::detect::Kind::Text => "text",
|
||||
printcraft_engine::detect::Kind::CheckBox => "checkbox",
|
||||
};
|
||||
json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) })
|
||||
})
|
||||
.collect();
|
||||
if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() {
|
||||
self.session.auto_detect_fields(id, &pages).map_err(failed)?;
|
||||
}
|
||||
Ok(json!({ "fields": list }))
|
||||
}
|
||||
|
||||
pub(crate) fn form_actions(&self, a: &Args) -> Result<Value> {
|
||||
use printcraft_engine::FieldAction as A;
|
||||
let list: Vec<Value> = self
|
||||
|
|
|
|||
|
|
@ -510,6 +510,7 @@ impl Automation {
|
|||
"form_set_script" => self.form_set_script(&a)?,
|
||||
"form_merge_data" => self.form_merge_data(&a)?,
|
||||
"form_actions" => self.form_actions(&a)?,
|
||||
"form_detect_fields" => self.form_detect_fields(&a)?,
|
||||
"form_set_actions" => self.form_set_actions(&a)?,
|
||||
"ocr_status" => self.ocr_status()?,
|
||||
"ocr_recognize_files" => self.ocr_recognize_files(&a)?,
|
||||
|
|
|
|||
|
|
@ -759,6 +759,9 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
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).")
|
||||
.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).")
|
||||
.ro()
|
||||
.cmd("form.prepare")
|
||||
|
|
|
|||
|
|
@ -1416,3 +1416,20 @@ fn field_actions_through_tools() {
|
|||
assert_eq!(run["requests"][0]["page"], 3);
|
||||
assert!(a.call("form_set_actions", &json!({ "doc": doc, "field": "Go", "actions": [{ "trigger": "wave" }] })).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detecting_form_fields_through_tools() {
|
||||
let dir = workdir("detect-fields");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Name: ____________________\n\nCity: ____________________" }))["doc"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let r = ok(&mut a, "form_detect_fields", json!({ "doc": doc, "add": false }));
|
||||
assert_eq!(r["fields"].as_array().unwrap().len(), 2, "{r}");
|
||||
assert_eq!(r["fields"][1]["name"], "City");
|
||||
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(), 0);
|
||||
ok(&mut a, "form_detect_fields", json!({ "doc": doc }));
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "City": "Lisbon" } }));
|
||||
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
assert_eq!(f["fields"][1]["value"], "Lisbon", "{f}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
|||
c("comment.summarize", "Summarize comments", None, None, HasComments, "file-text"),
|
||||
c("form.import_data", "Import form data…", None, None, HasFields, "file-input"),
|
||||
c("form.export_data", "Export form data…", None, None, HasFields, "file-output"),
|
||||
c("form.detect", "Detect form fields", None, None, Modification, "scan"),
|
||||
c("form.merge_data", "Merge data files into spreadsheet…", None, None, Nothing, "file-spreadsheet"),
|
||||
c("form.flatten", "Flatten form fields", None, None, HasFields, "layers"),
|
||||
c("form.prepare", "Prepare a form", None, None, Modification, "text-cursor-input"),
|
||||
|
|
|
|||
|
|
@ -225,3 +225,95 @@ impl Session {
|
|||
Ok(o)
|
||||
}
|
||||
}
|
||||
|
||||
/// A page's words for form-field detection, in user space (underscore runs split from the
|
||||
/// text around them).
|
||||
fn detection_words(text: &printcraft_render::PageText, info: &printcraft_render::PageInfo) -> Vec<printcraft_forms::detect::Word> {
|
||||
let mut words: Vec<(String, [f32; 4])> = Vec::new();
|
||||
let mut last_line = u32::MAX;
|
||||
// A space glyph ends the word before it.
|
||||
let mut gap = false;
|
||||
for (i, g) in text.glyphs.iter().enumerate() {
|
||||
let line = text.line_of.get(i).copied().unwrap_or(0);
|
||||
if g.text.trim().is_empty() {
|
||||
gap = true;
|
||||
continue;
|
||||
}
|
||||
let under = g.text.chars().all(|c| c == '_');
|
||||
let breaks = match words.last() {
|
||||
None => true,
|
||||
Some((w, _)) => gap || line != last_line || text.space_before.get(i).copied().unwrap_or(false) || w.chars().all(|c| c == '_') != under,
|
||||
};
|
||||
gap = false;
|
||||
last_line = line;
|
||||
match words.last_mut() {
|
||||
Some((w, r)) if !breaks => {
|
||||
w.push_str(&g.text);
|
||||
r[0] = r[0].min(g.rect[0]);
|
||||
r[1] = r[1].min(g.rect[1]);
|
||||
r[2] = r[2].max(g.rect[2]);
|
||||
r[3] = r[3].max(g.rect[3]);
|
||||
}
|
||||
_ => words.push((g.text.clone(), g.rect)),
|
||||
}
|
||||
}
|
||||
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];
|
||||
printcraft_forms::detect::Word { text, rect }
|
||||
})
|
||||
.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).
|
||||
pub fn detect_fields(&self, id: DocId, pages: &[usize]) -> Vec<(usize, printcraft_forms::detect::Candidate)> {
|
||||
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 = printcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() };
|
||||
let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), config);
|
||||
let mut taken: Vec<String> = doc.form.iter().map(|f| f.name.clone()).collect();
|
||||
let all = doc.info.pages.len();
|
||||
let list: Vec<usize> = 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 res =
|
||||
r.render(printcraft_render::RenderRequest { page, kind: printcraft_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 = printcraft_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 printcraft_forms::detect::detect(&words, &shapes, &existing, &taken) {
|
||||
taken.push(c.name.clone());
|
||||
out.push((page, c));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 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<Vec<String>, EditError> {
|
||||
let found = self.detect_fields(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 {
|
||||
page,
|
||||
rect: c.rect,
|
||||
kind: match c.kind {
|
||||
printcraft_forms::detect::Kind::Text => printcraft_forms::NewField::Text { multiline: c.rect[3] - c.rect[1] > 30.0 },
|
||||
printcraft_forms::detect::Kind::CheckBox => printcraft_forms::NewField::CheckBox,
|
||||
},
|
||||
name: Some(c.name),
|
||||
})
|
||||
.collect();
|
||||
self.apply(id, Edit::Batch { label: "Detect form fields".into(), edits })?;
|
||||
Ok(names)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ pub use printcraft_annot::{
|
|||
AttachIcon, FillMark, Markup, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup,
|
||||
StampKind, Style, rect_quad,
|
||||
};
|
||||
pub use printcraft_forms::detect;
|
||||
pub use printcraft_optimize as optimize;
|
||||
pub use printcraft_print as print;
|
||||
pub use printcraft_redact::patterns::{PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern};
|
||||
|
|
|
|||
|
|
@ -1299,3 +1299,22 @@ fn form_javascript_validates_calculates_and_formats() {
|
|||
assert_eq!(value(&s, "total"), ["12"]);
|
||||
assert!(s.run_javascript(id, "1", None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detecting_fields_on_a_printed_form() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let text = s.create_from_text("t", "Name: ______________________\n\nEmail address: ____________________\n\nPlain text without blanks.").unwrap();
|
||||
let id = s.open("paper.pdf", None, text, None).unwrap();
|
||||
let found = s.detect_fields(id, &[]);
|
||||
let names: Vec<&str> = found.iter().map(|(_, c)| c.name.as_str()).collect();
|
||||
assert_eq!(names, ["Name", "Email address"], "{found:?}");
|
||||
let added = s.auto_detect_fields(id, &[]).unwrap();
|
||||
assert_eq!(added, ["Name", "Email address"]);
|
||||
let d = s.get(id).unwrap();
|
||||
assert_eq!(d.form.len(), 2);
|
||||
assert_eq!(d.can_undo(), Some("Detect form fields"));
|
||||
// The field sits on the blank: right of the label, at its height.
|
||||
let f = &d.form[0];
|
||||
assert!(f.widgets[0].rect[0] > 72.0 + 20.0, "{:?}", f.widgets[0].rect);
|
||||
assert!(s.detect_fields(id, &[]).is_empty(), "nothing left to detect");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ workspace = true
|
|||
[dependencies]
|
||||
printcraft-cos = { workspace = true }
|
||||
printcraft-fonts = { workspace = true }
|
||||
printcraft-content = { workspace = true }
|
||||
printcraft-model = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,270 @@
|
|||
//! 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).
|
||||
//!
|
||||
//! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads
|
||||
//! the shapes from a page's content.
|
||||
|
||||
use printcraft_content::{Matrix, parse};
|
||||
use printcraft_cos::{Document, Object};
|
||||
|
||||
/// A piece of text on the page, in user space.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Word {
|
||||
pub text: String,
|
||||
/// [x0, y0, x1, y1], y up.
|
||||
pub rect: [f64; 4],
|
||||
}
|
||||
|
||||
/// Shapes drawn on the page, in user space: rectangles (`re`) and straight horizontal
|
||||
/// segments.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Shapes {
|
||||
pub boxes: Vec<[f64; 4]>,
|
||||
pub rules: Vec<[f64; 4]>,
|
||||
}
|
||||
|
||||
/// What detection proposes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Kind {
|
||||
Text,
|
||||
CheckBox,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Candidate {
|
||||
pub kind: Kind,
|
||||
pub rect: [f64; 4],
|
||||
/// From the nearby label, made a valid field name.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// The rectangles and horizontal segments a page draws (stroked or filled), in user space.
|
||||
pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
|
||||
let mut out = Shapes::default();
|
||||
let Some(p) = printcraft_model::pages(doc).into_iter().nth(page) else { return out };
|
||||
let list: Vec<Object> = match p.dict.get(b"Contents").map(|c| doc.resolve(c)) {
|
||||
Some(c) => match &*c {
|
||||
Object::Array(a) => a.clone(),
|
||||
Object::Stream(_) => vec![p.dict.get(b"Contents").cloned().unwrap_or(Object::Null)],
|
||||
_ => Vec::new(),
|
||||
},
|
||||
None => Vec::new(),
|
||||
};
|
||||
let mut data = Vec::new();
|
||||
for o in list {
|
||||
if let Object::Stream(s) = &*doc.resolve(&o) {
|
||||
data.extend(s.decoded().unwrap_or_default());
|
||||
data.push(b'\n');
|
||||
}
|
||||
}
|
||||
let mut ctm = Matrix::IDENTITY;
|
||||
let mut stack = Vec::new();
|
||||
let mut path_boxes: Vec<[f64; 4]> = Vec::new();
|
||||
let mut path_rules: Vec<[f64; 4]> = Vec::new();
|
||||
let mut cur: Option<[f64; 2]> = None;
|
||||
for op in parse(&data).ops {
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(ctm),
|
||||
b"Q" => ctm = stack.pop().unwrap_or(ctm),
|
||||
b"cm" => {
|
||||
if let Some(m) = op.nums::<6>() {
|
||||
ctm = Matrix(m).then(&ctm);
|
||||
}
|
||||
}
|
||||
b"re" => {
|
||||
if let Some([x, y, w, h]) = op.nums::<4>() {
|
||||
path_boxes.push(ctm.bbox([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]));
|
||||
}
|
||||
}
|
||||
b"m" => {
|
||||
cur = op.nums::<2>().map(|[x, y]| {
|
||||
let (a, b) = ctm.apply(x, y);
|
||||
[a, b]
|
||||
})
|
||||
}
|
||||
b"l" => {
|
||||
if let (Some(a), Some([x, y])) = (cur, op.nums::<2>()) {
|
||||
let (bx, by) = ctm.apply(x, y);
|
||||
let b = [bx, by];
|
||||
if (a[1] - b[1]).abs() < 0.5 && (a[0] - b[0]).abs() > 1.0 {
|
||||
path_rules.push([a[0].min(b[0]), a[1], a[0].max(b[0]), a[1]]);
|
||||
}
|
||||
cur = Some(b);
|
||||
}
|
||||
}
|
||||
b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
|
||||
for r in path_boxes.drain(..) {
|
||||
// A very flat filled rectangle is a rule.
|
||||
if r[3] - r[1] < 1.5 && r[2] - r[0] > 1.0 {
|
||||
out.rules.push([r[0], (r[1] + r[3]) / 2.0, r[2], (r[1] + r[3]) / 2.0]);
|
||||
} else {
|
||||
out.boxes.push(r);
|
||||
}
|
||||
}
|
||||
out.rules.append(&mut path_rules);
|
||||
cur = None;
|
||||
}
|
||||
b"n" => {
|
||||
path_boxes.clear();
|
||||
path_rules.clear();
|
||||
cur = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn mid(r: [f64; 4]) -> f64 {
|
||||
(r[1] + r[3]) / 2.0
|
||||
}
|
||||
|
||||
/// A label's words made into a field name: letters, digits and spaces, no trailing colon.
|
||||
fn clean(label: &str) -> String {
|
||||
let s: String = label.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' || c == '/' { c } else { ' ' }).collect();
|
||||
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
s.chars().take(40).collect::<String>().trim().to_string()
|
||||
}
|
||||
|
||||
/// The label for a field at `rect`: the words to its left on the same line, else the words
|
||||
/// just above it.
|
||||
fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String {
|
||||
let h = (rect[3] - rect[1]).max(8.0);
|
||||
let same_line = |w: &Word| (mid(w.rect) - mid(rect)).abs() < h.max(10.0) * 0.7;
|
||||
let mut left: Vec<&Word> = if right_of {
|
||||
words.iter().filter(|w| same_line(w) && w.rect[0] >= rect[2] - 1.0 && w.rect[0] - rect[2] < 200.0).collect()
|
||||
} else {
|
||||
words.iter().filter(|w| same_line(w) && w.rect[2] <= rect[0] + 1.0 && rect[0] - w.rect[2] < 250.0).collect()
|
||||
};
|
||||
left.sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0]));
|
||||
if right_of {
|
||||
// The words that follow the box, up to the next gap wider than a few spaces.
|
||||
let mut picked = Vec::new();
|
||||
let mut x = rect[2];
|
||||
for w in left {
|
||||
if w.rect[0] - x > 24.0 {
|
||||
break;
|
||||
}
|
||||
picked.push(w.text.as_str());
|
||||
x = w.rect[2];
|
||||
}
|
||||
return clean(&picked.join(" "));
|
||||
}
|
||||
// The words that end nearest the field, back to a wide gap.
|
||||
let mut picked: Vec<&str> = Vec::new();
|
||||
let mut x = rect[0];
|
||||
for w in left.iter().rev() {
|
||||
if x - w.rect[2] > 24.0 && !picked.is_empty() {
|
||||
break;
|
||||
}
|
||||
if w.text.chars().all(|c| c == '_') {
|
||||
break;
|
||||
}
|
||||
picked.insert(0, w.text.as_str());
|
||||
x = w.rect[0];
|
||||
}
|
||||
let l = clean(&picked.join(" "));
|
||||
if !l.is_empty() {
|
||||
return l;
|
||||
}
|
||||
// Above: the nearest line of words over the field's left part.
|
||||
let above: Vec<&Word> = words
|
||||
.iter()
|
||||
.filter(|w| w.rect[1] >= rect[3] - 1.0 && w.rect[1] - rect[3] < 24.0 && w.rect[0] < rect[2] && w.rect[2] > rect[0] - 4.0)
|
||||
.collect();
|
||||
let Some(line) = above.iter().map(|w| mid(w.rect)).min_by(|a, b| a.total_cmp(b)) else { return String::new() };
|
||||
let mut on: Vec<&&Word> = above.iter().filter(|w| (mid(w.rect) - line).abs() < 3.0).collect();
|
||||
on.sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0]));
|
||||
clean(&on.iter().map(|w| w.text.as_str()).collect::<Vec<_>>().join(" "))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let mut out: Vec<Candidate> = Vec::new();
|
||||
let overlaps = |a: [f64; 4], b: [f64; 4]| a[0] < b[2] - 1.0 && b[0] < a[2] - 1.0 && a[1] < b[3] - 1.0 && b[1] < a[3] - 1.0;
|
||||
let push = |kind: Kind, rect: [f64; 4], label: String, out: &mut Vec<Candidate>| {
|
||||
if existing.iter().chain(out.iter().map(|c| &c.rect)).any(|e| overlaps(*e, rect)) {
|
||||
return;
|
||||
}
|
||||
out.push(Candidate { kind, rect, name: label });
|
||||
};
|
||||
// Text that isn't underscores (labels), and underscore runs (blanks).
|
||||
let (blanks, labels): (Vec<&Word>, Vec<&Word>) = words.iter().partition(|w| w.text.len() >= 3 && w.text.chars().all(|c| c == '_'));
|
||||
let labels: Vec<Word> = labels.into_iter().cloned().collect();
|
||||
for b in blanks {
|
||||
let h = (b.rect[3] - b.rect[1]).max(10.0);
|
||||
let rect = [b.rect[0], b.rect[1], b.rect[2], b.rect[1] + (h * 1.4).clamp(12.0, 22.0)];
|
||||
let l = label_for(&labels, rect, false);
|
||||
push(Kind::Text, 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]);
|
||||
if (5.0..=20.0).contains(&w) && (5.0..=20.0).contains(&h) && (w / h - 1.0).abs() < 0.25 {
|
||||
let l = label_for(&labels, *r, true);
|
||||
push(Kind::CheckBox, *r, l, &mut out);
|
||||
}
|
||||
}
|
||||
// Empty boxes big enough to write in: text fields inside them.
|
||||
for r in &shapes.boxes {
|
||||
let (w, h) = (r[2] - r[0], r[3] - r[1]);
|
||||
let has_text = labels.iter().any(|x| overlaps(x.rect, *r));
|
||||
if w >= 40.0 && (12.0..=60.0).contains(&h) && !has_text {
|
||||
let inner = [r[0] + 1.0, r[1] + 1.0, r[2] - 1.0, r[3] - 1.0];
|
||||
let l = label_for(&labels, *r, false);
|
||||
push(Kind::Text, inner, l, &mut out);
|
||||
}
|
||||
}
|
||||
// Rules: a text field sitting on the line.
|
||||
for r in &shapes.rules {
|
||||
let w = r[2] - r[0];
|
||||
if w < 40.0 {
|
||||
continue;
|
||||
}
|
||||
// A line that underlines text (or a table border with text on it) isn't a blank.
|
||||
let rect = [r[0], r[1] + 1.0, r[2], r[1] + 17.0];
|
||||
if labels.iter().any(|x| overlaps(x.rect, rect)) {
|
||||
continue;
|
||||
}
|
||||
let l = label_for(&labels, rect, false);
|
||||
push(Kind::Text, rect, l, &mut out);
|
||||
}
|
||||
// Reading order: top to bottom, left to right.
|
||||
// Rows: fields whose middles are within 8 pt of the row's first.
|
||||
out.sort_by(|a, b| mid(b.rect).total_cmp(&mid(a.rect)));
|
||||
let mut rows = Vec::with_capacity(out.len());
|
||||
let (mut row, mut start) = (0usize, f64::INFINITY);
|
||||
for c in &out {
|
||||
if start - mid(c.rect) > 8.0 {
|
||||
row += 1;
|
||||
start = mid(c.rect);
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
let mut keyed: Vec<(usize, Candidate)> = rows.into_iter().zip(out).collect();
|
||||
keyed.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.rect[0].total_cmp(&b.1.rect[0])));
|
||||
let mut out: Vec<Candidate> = keyed.into_iter().map(|(_, c)| c).collect();
|
||||
// Unique names (Acrobat numbers repeats); unlabelled fields get Text1, Check Box1, …
|
||||
let mut used: Vec<String> = taken.to_vec();
|
||||
for c in &mut out {
|
||||
let base = if c.name.is_empty() {
|
||||
match c.kind {
|
||||
Kind::Text => "Text".to_string(),
|
||||
Kind::CheckBox => "Check Box".to_string(),
|
||||
}
|
||||
} else {
|
||||
c.name.clone()
|
||||
};
|
||||
let mut name = if c.name.is_empty() { format!("{base}1") } else { base.clone() };
|
||||
let mut n = 1;
|
||||
while used.contains(&name) {
|
||||
n += 1;
|
||||
name = format!("{base}{n}");
|
||||
}
|
||||
used.push(name.clone());
|
||||
c.name = name;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ mod actions;
|
|||
pub mod af;
|
||||
pub mod appearance;
|
||||
mod author;
|
||||
pub mod detect;
|
||||
mod scripting;
|
||||
pub use actions::{FieldAction, Trigger, field_actions, set_field_actions};
|
||||
pub use author::{
|
||||
|
|
|
|||
|
|
@ -659,3 +659,44 @@ fn field_actions_round_trip_on_every_trigger() {
|
|||
set_field_actions(&mut doc, &name, &[(Trigger::MouseUp, FieldAction::JavaScript("this.print();".into()))]).unwrap();
|
||||
assert_eq!(fields(&doc).iter().find(|f| f.name == "go").unwrap().button, Some(af::ButtonAction::Named("Print".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detection_finds_blanks_rules_boxes_and_names_them() {
|
||||
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("Name:", 50.0, 700.0, 80.0),
|
||||
w("______________", 85.0, 699.0, 250.0),
|
||||
w("Date", 300.0, 700.0, 325.0),
|
||||
w("of", 328.0, 700.0, 338.0),
|
||||
w("birth", 341.0, 700.0, 365.0),
|
||||
w("I", 72.0, 602.0, 75.0),
|
||||
w("agree", 78.0, 602.0, 105.0),
|
||||
w("Comments", 50.0, 560.0, 100.0),
|
||||
w("Heading", 50.0, 750.0, 100.0),
|
||||
];
|
||||
let shapes = Shapes {
|
||||
boxes: vec![[56.0, 600.0, 68.0, 612.0], [50.0, 500.0, 300.0, 540.0], [40.0, 20.0, 560.0, 780.0]],
|
||||
rules: vec![[370.0, 699.0, 500.0, 699.0], [50.0, 748.0, 100.0, 748.0]],
|
||||
};
|
||||
let found = detect(&words, &shapes, &[], &["Comments".into()]);
|
||||
let names: Vec<(&str, &Kind)> = found.iter().map(|c| (c.name.as_str(), &c.kind)).collect();
|
||||
assert_eq!(names, [("Name", &Kind::Text), ("Date of birth", &Kind::Text), ("I agree", &Kind::CheckBox), ("Comments2", &Kind::Text)], "{found:?}");
|
||||
// An existing field there: nothing new.
|
||||
let again = detect(&words, &shapes, &found.iter().map(|c| c.rect).collect::<Vec<_>>(), &[]);
|
||||
assert!(again.is_empty(), "{again:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_shapes_reads_boxes_and_rules() {
|
||||
let mut doc = fixture();
|
||||
let page = printcraft_model::pages(&doc)[0].obj;
|
||||
let s = doc.add(printcraft_cos::Object::Stream(printcraft_cos::Stream::flate(
|
||||
Default::default(),
|
||||
b"q 2 0 0 2 0 0 cm 10 10 6 6 re S 20 50 m 120 50 l S 0 0 m 5 5 l S 30 300 100 0.5 re f Q",
|
||||
)));
|
||||
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), printcraft_cos::Object::Ref(s))).unwrap();
|
||||
let sh = crate::detect::page_shapes(&doc, 0);
|
||||
assert_eq!(sh.boxes, [[20.0, 20.0, 32.0, 32.0]]);
|
||||
assert_eq!(sh.rules, [[40.0, 100.0, 240.0, 100.0], [60.0, 600.5, 260.0, 600.5]]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -365,7 +365,14 @@ impl PrintCraftApp {
|
|||
self.left = crate::LeftPanel::Tool("form");
|
||||
self.left_open = true;
|
||||
self.right = Some(RightPanel::Fields);
|
||||
// Like Acrobat, a document without fields gets them detected on the way in.
|
||||
if let Some((_, id)) = self.active_ids()
|
||||
&& self.session.get(id).is_some_and(|d| d.form.is_empty() && d.editable())
|
||||
{
|
||||
self.detect_fields();
|
||||
}
|
||||
}
|
||||
"form.detect" => self.detect_fields(),
|
||||
"form.field.properties" => {
|
||||
if let Some((name, w)) = active.and_then(|i| self.views[i].prepare.selected.clone()) {
|
||||
self.open_field_props(&name, w);
|
||||
|
|
|
|||
|
|
@ -75,6 +75,21 @@ impl PrintCraftApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Prepare a form ▸ detect fields from the page's blanks, lines and boxes.
|
||||
pub fn detect_fields(&mut self) {
|
||||
let Some((i, id)) = self.active_ids() else { return };
|
||||
match self.session.auto_detect_fields(id, &[]) {
|
||||
Ok(names) if names.is_empty() => self.notify("No form fields were detected"),
|
||||
Ok(names) => {
|
||||
if let Some(info) = self.session.get(id).map(|d| d.info.clone()) {
|
||||
self.views[i].document_changed(&info);
|
||||
}
|
||||
self.notify(format!("Detected {} form field{}", names.len(), if names.len() == 1 { "" } else { "s" }));
|
||||
}
|
||||
Err(e) => self.notify(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The console's Run: evaluate the input in the active document.
|
||||
pub fn run_console(&mut self) {
|
||||
let Some((i, id)) = self.active_ids() else { return };
|
||||
|
|
|
|||
|
|
@ -115,3 +115,22 @@ fn merge_data_files_into_a_spreadsheet() {
|
|||
app.merge_data_files(vec![("x.fdf".into(), b"junk".to_vec())]);
|
||||
assert!(app.toast.clone().unwrap().0.starts_with("x.fdf:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_a_form_detects_fields_on_a_paper_form() {
|
||||
let paper =
|
||||
printcraft_engine::Session::new().create_from_text("t", "Name: ____________________\n\nPhone: ____________________").unwrap().to_vec();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.open_bytes("paper.pdf", None, paper.clone()).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert!(h.state_mut().execute("form.prepare"));
|
||||
h.run_steps(3);
|
||||
let app = h.state();
|
||||
let id = app.active_ids().unwrap().1;
|
||||
let names: Vec<String> = app.session.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect();
|
||||
assert_eq!(names, ["Name", "Phone"]);
|
||||
assert_eq!(app.toast.clone().unwrap().0, "Detected 2 form fields");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4959,11 +4959,11 @@ title = "Automatic form field detection"
|
|||
area = "F"
|
||||
tier = "P1"
|
||||
milestone = "M6"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
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"]
|
||||
notes = "Underscore runs, rules, empty boxes and small squares (check boxes); runs when Prepare a form starts on a document without fields. Not yet: comb boxes, tables of cells, scanned forms (needs OCR first), radio groups."
|
||||
|
||||
[[feature]]
|
||||
id = "form.auto-name-fields"
|
||||
|
|
@ -4971,11 +4971,11 @@ title = "Name new fields from nearby labels"
|
|||
area = "F"
|
||||
tier = "P1"
|
||||
milestone = "M6"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "shipped"
|
||||
commands = ["form.detect"]
|
||||
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"]
|
||||
notes = "Names come from the label to the left (or above; right of check boxes), unique like Acrobat's (Name2, Text1)."
|
||||
|
||||
[[feature]]
|
||||
id = "form.document-javascripts"
|
||||
|
|
|
|||
Loading…
Reference in New Issue