mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 18:47:22 -06:00
Field detection read only vector data: text-layer words and drawn rectangles and lines. A scanned form is a single picture, so it found nothing there. A page that an image covers at least half of, and whose content draws no lines, is now rendered at 144 dpi and its shapes are read from the pixels (pdfcraft_forms::detect::raster): ink against a local mean, long thin runs as lines, enclosed rectangular patches of paper as boxes, the empty band of a box with a small printed label as its writing area, and the open-ended horizontal lines as rules. Labels come from the text layer, else from OCR when the models are installed; without either the fields get default names. Detection works in the displayed frame, so a scan stored sideways reads like any other, and fields on a turned page get /MK /R so typing reads upright. generated with Claude Code
376 lines
14 KiB
Rust
376 lines
14 KiB
Rust
//! 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, [`raster::shapes`] from a picture of the page (a scan).
|
|
|
|
use pdfcraft_content::{Matrix, parse};
|
|
use pdfcraft_cos::{Document, Object};
|
|
|
|
pub mod raster;
|
|
|
|
/// 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: axis-aligned rectangle paths and 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.
|
|
#[derive(Default)]
|
|
struct DetectionPath {
|
|
points: Vec<[f64; 2]>,
|
|
rules: Vec<[f64; 4]>,
|
|
complex: bool,
|
|
closed: bool,
|
|
}
|
|
|
|
impl DetectionPath {
|
|
fn finish(&mut self, paths: &mut Vec<Self>) {
|
|
let path = std::mem::take(self);
|
|
if !path.rules.is_empty() || (!path.complex && path.points.len() >= 4) {
|
|
paths.push(path);
|
|
}
|
|
}
|
|
|
|
fn rectangle(&self, implicitly_closed: bool) -> Option<[f64; 4]> {
|
|
if self.complex {
|
|
return None;
|
|
}
|
|
if self.points.iter().flatten().any(|v| !v.is_finite()) {
|
|
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 repeated_end = points.len() > 1 && near(points[0], *points.last()?);
|
|
if repeated_end {
|
|
points = &points[..points.len() - 1];
|
|
}
|
|
if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) {
|
|
return None;
|
|
}
|
|
let mut horizontal = [false; 4];
|
|
for i in 0..4 {
|
|
let (a, b) = (points[i], points[(i + 1) % 4]);
|
|
let h = (a[1] - b[1]).abs() < 1e-6;
|
|
let v = (a[0] - b[0]).abs() < 1e-6;
|
|
if h == v {
|
|
return None; // Diagonal or zero-length edge.
|
|
}
|
|
horizontal[i] = h;
|
|
}
|
|
if (0..4).any(|i| horizontal[i] == horizontal[(i + 1) % 4]) {
|
|
return None;
|
|
}
|
|
Some([
|
|
points.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min),
|
|
points.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min),
|
|
points.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max),
|
|
points.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max),
|
|
])
|
|
}
|
|
}
|
|
|
|
pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
|
|
let mut out = Shapes::default();
|
|
let Some(p) = pdfcraft_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 subpaths: Vec<DetectionPath> = Vec::new();
|
|
let mut path = DetectionPath::default();
|
|
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" => {
|
|
path.finish(&mut subpaths);
|
|
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)]));
|
|
let (x, y) = ctm.apply(x, y);
|
|
cur = Some([x, y]);
|
|
path.complex = true;
|
|
path.points.push([x, y]);
|
|
}
|
|
}
|
|
b"m" => {
|
|
path.finish(&mut subpaths);
|
|
cur = op.nums::<2>().map(|[x, y]| {
|
|
let (a, b) = ctm.apply(x, y);
|
|
[a, b]
|
|
});
|
|
if let Some(p) = cur {
|
|
path.points.push(p);
|
|
}
|
|
}
|
|
b"l" => {
|
|
if let (Some(a), Some([x, y])) = (cur, op.nums::<2>()) {
|
|
// Continuing after closepath is not a single four-edge rectangle.
|
|
if path.closed {
|
|
path.complex = true;
|
|
}
|
|
let (bx, by) = ctm.apply(x, y);
|
|
let b = [bx, by];
|
|
if !path.complex {
|
|
if path.points.len() >= 5 {
|
|
path.complex = true;
|
|
} else {
|
|
if path.points.is_empty() {
|
|
path.points.push(a);
|
|
}
|
|
path.points.push(b);
|
|
}
|
|
}
|
|
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"c" | b"v" | b"y" => {
|
|
path.complex = true;
|
|
let end = if op.op.as_slice() == b"c" { op.nums::<6>().map(|v| [v[4], v[5]]) } else { op.nums::<4>().map(|v| [v[2], v[3]]) };
|
|
if let Some([x, y]) = end {
|
|
let (x, y) = ctm.apply(x, y);
|
|
cur = Some([x, y]);
|
|
}
|
|
}
|
|
b"h" => {
|
|
path.closed = true;
|
|
cur = path.points.first().copied().or(cur);
|
|
}
|
|
b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
|
|
if matches!(op.op.as_slice(), b"s" | b"b" | b"b*") {
|
|
path.closed = true;
|
|
}
|
|
path.finish(&mut subpaths);
|
|
let filled = !matches!(op.op.as_slice(), b"S" | b"s");
|
|
let mut path_rules = Vec::new();
|
|
for subpath in subpaths.drain(..) {
|
|
if let Some(rect) = subpath.rectangle(filled) {
|
|
path_boxes.push(rect);
|
|
} else {
|
|
path_rules.extend(subpath.rules);
|
|
}
|
|
}
|
|
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();
|
|
subpaths.clear();
|
|
path = DetectionPath::default();
|
|
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
|
|
}
|