Forms #294: detect rectangles drawn with closed line paths (#1596)

Refs #294. Preserve rectangle geometry without inventing rule fields from its edges.

Co-authored-by: OpenAI Codex <[email protected]>
This commit is contained in:
FredAppsandOpenAI Codex authored and GitHub committed 2026-10-13 00:10:30 -04:00
1 parent 29f4edf5be
commit 0b417441f6
2 files changed
+187 -6

No files matched your search

+109 -6
View File
@@ -16,8 +16,7 @@ pub struct Word {
pub rect: [f64; 4],
}
/// Shapes drawn on the page, in user space: rectangles (`re`) and straight horizontal
/// segments.
/// 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]>,
@@ -40,6 +39,60 @@ pub struct Candidate {
}
/// 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 };
@@ -61,7 +114,8 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
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 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() {
@@ -73,27 +127,75 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
}
}
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]]);
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 {
@@ -107,7 +209,8 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
}
b"n" => {
path_boxes.clear();
path_rules.clear();
subpaths.clear();
path = DetectionPath::default();
cur = None;
}
_ => {}
+78
View File
@@ -1865,3 +1865,81 @@ fn invalid_appearance_patches_are_refused_before_anything_is_written() {
assert!(set_props(&mut doc, "name", &props).is_err());
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before);
}
#[test]
fn page_shapes_preserves_closed_path_rectangle_height() {
let shapes = |content: &[u8]| {
let mut doc = fixture();
let page = pdfcraft_model::pages(&doc)[0].obj;
let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), content)));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
crate::detect::page_shapes(&doc, 0)
};
let shorthand = shapes(b"50 100 120 40 re S");
let outlined = shapes(b"50 100 m 170 100 l 170 140 l 50 140 l h S");
assert_eq!(outlined, shorthand, "equivalent rectangle encodings must have equal detection geometry");
let fields = crate::detect::detect(&[], &outlined, &[], &[]);
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].rect, [51.0, 101.0, 169.0, 139.0]);
}
fn detection_test_shapes(content: &[u8]) -> crate::detect::Shapes {
let mut doc = fixture();
let page = pdfcraft_model::pages(&doc)[0].obj;
let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), content)));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream))).unwrap();
crate::detect::page_shapes(&doc, 0)
}
#[test]
fn page_shapes_accepts_rectangle_closure_and_paint_variants() {
for end in ["h S", "s", "50 100 l S", "f", "F", "f*", "B", "B*", "b", "b*"] {
let content = format!("50 100 m 170 100 l 170 140 l 50 140 l {end}");
let shapes = detection_test_shapes(content.as_bytes());
assert_eq!(shapes.boxes, [[50.0, 100.0, 170.0, 140.0]], "{end}");
assert!(shapes.rules.is_empty(), "rectangle edges must not become extra fields: {end}");
}
}
#[test]
fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() {
for content in [
"50 100 m 170 100 l 170 140 l 50 140 l S",
"50 100 m 170 110 l 170 150 l 50 140 l h S",
"50 100 m 170 100 l 170 140 l 100 150 80 150 50 140 c h S",
"50 100 m 170 100 l 170 140 l h 50 140 l f",
"50 100 m 170 100 l 50 100 l 50 140 l h S",
] {
assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}");
}
}
#[test]
fn page_shapes_preserves_multiple_subpaths_and_discards_unpainted_paths() {
let shapes = detection_test_shapes(b"50 100 120 40 re 200 100 m 320 100 l 320 140 l 200 140 l h 50 400 m 170 400 l S");
assert_eq!(shapes.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]);
assert_eq!(shapes.rules, [[50.0, 400.0, 170.0, 400.0]]);
assert_eq!(detection_test_shapes(b"50 100 m 170 100 l 170 140 l 50 140 l h W n"), crate::detect::Shapes::default());
let continued = detection_test_shapes(b"50 100 120 40 re 170 100 l 170 140 l 50 140 l h S");
assert_eq!(continued.boxes, [[50.0, 100.0, 170.0, 140.0]], "continuing re must not invent a second box");
}
#[test]
fn page_shapes_transforms_path_rectangles_and_preserves_rules() {
let scaled = detection_test_shapes(b"q 2 0 0 2 0 0 cm 50 100 m 170 100 l 170 140 l 50 140 l h S Q 10 20 m 100 20 l S");
assert_eq!(scaled.boxes, [[100.0, 200.0, 340.0, 280.0]]);
assert_eq!(scaled.rules, [[10.0, 20.0, 100.0, 20.0]]);
let rotated = detection_test_shapes(b"0 1 -1 0 300 0 cm 50 100 m 170 100 l 170 140 l 50 140 l h S");
assert_eq!(rotated.boxes, [[160.0, 50.0, 200.0, 170.0]]);
}
#[test]
fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() {
let paths = "50 100 m 170 100 l 170 140 l 50 140 l 200 100 m 320 100 l 320 140 l 200 140 l";
let stroked = detection_test_shapes(format!("{paths} s").as_bytes());
assert_eq!(stroked.boxes, [[200.0, 100.0, 320.0, 140.0]]);
assert_eq!(stroked.rules, [[50.0, 100.0, 170.0, 100.0], [50.0, 140.0, 170.0, 140.0]]);
let filled = detection_test_shapes(format!("{paths} f").as_bytes());
assert_eq!(filled.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]);
assert!(filled.rules.is_empty());
}