diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index d7a57482c6..685a818a9c 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -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) { + 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 = 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; } _ => {} diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 1e913a5b5c..0256bfafb9 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -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()); +}