Skip to main content

docling_pdf/
checkbox.rs

1//! Vector checkboxes (#609): the small squares a generator *draws* in front of
2//! a checklist's lines, found in the page's path painting.
3//!
4//! docling knows a checkbox only from the layout model's `checkbox_selected` /
5//! `checkbox_unselected` labels, and Heron gives them to drawn squares
6//! sparingly — on the #609 reporter's ReportLab checklist it labels the four
7//! options one `text` region, which docling then serializes as two garbled
8//! paragraphs (`First option Third option` / `Second option Fourth option`,
9//! its cell assignment alternating between two overlapping text clusters).
10//! The squares themselves are unambiguous in the content stream, so the text
11//! parser records the strokes it walks past ([`Ink`]) and [`find`] turns them
12//! into [`CheckBox`]es; assembly then gives each boxed line its own checkbox
13//! item ([`crate::assemble::split_checkbox_lines`]).
14//!
15//! A square is an outline — a stroked `re`, or four stroked axis-aligned
16//! edges meeting at the corners (ReportLab draws each edge as its own
17//! `m … l S` path) — between [`MIN_SIDE`] and [`MAX_SIDE`] points with sides
18//! within 15 % of each other. Anything else painted inside it (a tick, a
19//! cross, a filled dot) marks it checked. A square filled with colour — a
20//! stroked-and-filled `re`, or one under a fill of its own size — is a chart
21//! legend's swatch, not a checkbox; a white fill (a form's box background)
22//! draws nothing and changes nothing.
23
24/// A checkbox square, top-left page points (the [`TextCell`] frame).
25///
26/// [`TextCell`]: crate::pdfium_backend::TextCell
27#[derive(Debug, Clone, Copy, PartialEq)]
28pub struct CheckBox {
29    pub l: f32,
30    pub t: f32,
31    pub r: f32,
32    pub b: f32,
33    pub checked: bool,
34}
35
36/// One painted piece of a path, y-up page points (the glyphs' frame).
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub(crate) enum Ink {
39    /// A stroked straight segment.
40    Seg { x0: f64, y0: f64, x1: f64, y1: f64 },
41    /// A stroked (or stroked-and-filled) rectangle, axis-aligned on the page.
42    Rect { l: f64, b: f64, r: f64, t: f64 },
43    /// Anything else painted — a fill, a curve: its bounding box, which can
44    /// only ever be a mark inside a square.
45    Blot { l: f64, b: f64, r: f64, t: f64 },
46}
47
48/// Smallest and largest checkbox side, points: below 5 pt a square is a
49/// bullet or a table hairline's corner, above 24 pt a frame or a swatch.
50pub(crate) const MIN_SIDE: f64 = 5.0;
51pub(crate) const MAX_SIDE: f64 = 24.0;
52/// Corner/edge matching slack, points (line caps, rounding).
53const TOL: f64 = 0.75;
54
55impl Ink {
56    fn bbox(&self) -> (f64, f64, f64, f64) {
57        match *self {
58            Ink::Seg { x0, y0, x1, y1 } => (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)),
59            Ink::Rect { l, b, r, t } | Ink::Blot { l, b, r, t } => (l, b, r, t),
60        }
61    }
62
63    /// Whether the text parser should keep this piece at all: only what can
64    /// be a checkbox edge or fit inside one. Keeps a vector-heavy page (a
65    /// chart, a map) from buffering every path it draws.
66    pub(crate) fn worth_keeping(&self) -> bool {
67        let (l, b, r, t) = self.bbox();
68        let reach = MAX_SIDE + TOL;
69        (r - l) <= reach && (t - b) <= reach
70    }
71}
72
73/// A square's `(l, b, r, t)` and the ink indices that draw it.
74type Square = ((f64, f64, f64, f64), Vec<usize>);
75
76fn square_sides(w: f64, h: f64) -> bool {
77    let (lo, hi) = (w.min(h), w.max(h));
78    lo >= MIN_SIDE && hi <= MAX_SIDE && hi - lo <= 0.15 * hi
79}
80
81/// The checkboxes among a page's painted pieces, top-left page points
82/// (`page_h` flips y). Duplicates (a square stroked twice) collapse.
83pub(crate) fn find(inks: &[Ink], page_h: f32) -> Vec<CheckBox> {
84    let mut squares: Vec<Square> = Vec::new();
85    for (i, ink) in inks.iter().enumerate() {
86        if let Ink::Rect { l, b, r, t } = *ink {
87            if square_sides(r - l, t - b) {
88                squares.push(((l, b, r, t), vec![i]));
89            }
90        }
91    }
92    // Four separate edges. Horizontal edges pair up by a shared x span, and
93    // the two verticals must close the sides at both ends.
94    let near = |a: f64, b: f64| (a - b).abs() <= TOL;
95    let mut hs: Vec<(usize, f64, f64, f64)> = Vec::new(); // (ink, y, xl, xr)
96    let mut vs: Vec<(usize, f64, f64, f64)> = Vec::new(); // (ink, x, yb, yt)
97    for (i, ink) in inks.iter().enumerate() {
98        if let Ink::Seg { x0, y0, x1, y1 } = *ink {
99            if near(y0, y1) && (x1 - x0).abs() >= MIN_SIDE - TOL {
100                hs.push((i, (y0 + y1) / 2.0, x0.min(x1), x0.max(x1)));
101            } else if near(x0, x1) && (y1 - y0).abs() >= MIN_SIDE - TOL {
102                vs.push((i, (x0 + x1) / 2.0, y0.min(y1), y0.max(y1)));
103            }
104        }
105    }
106    // A page drawn from thousands of short strokes (hatching, a plotted
107    // curve) is not a checklist; the pairing below is quadratic.
108    if hs.len() <= 4000 && vs.len() <= 4000 {
109        let side = |lo: f64, hi: f64, a: f64, b: f64| near(lo, a) && near(hi, b);
110        for (x, &(hi, ya, xl, xr)) in hs.iter().enumerate() {
111            for &(hj, yb, xl2, xr2) in &hs[x + 1..] {
112                if !(near(xl, xl2) && near(xr, xr2) && square_sides(xr - xl, (ya - yb).abs())) {
113                    continue;
114                }
115                let (bot, top) = (ya.min(yb), ya.max(yb));
116                let left = vs
117                    .iter()
118                    .find(|&&(_, vx, y0, y1)| near(vx, xl) && side(y0, y1, bot, top));
119                let right = vs
120                    .iter()
121                    .find(|&&(_, vx, y0, y1)| near(vx, xr) && side(y0, y1, bot, top));
122                if let (Some(&(lv, ..)), Some(&(rv, ..))) = (left, right) {
123                    squares.push(((xl, bot, xr, top), vec![hi, hj, lv, rv]));
124                }
125            }
126        }
127    }
128    let mut out: Vec<CheckBox> = Vec::new();
129    for (k, &((l, b, r, t), ref own)) in squares.iter().enumerate() {
130        let same =
131            |o: &Square| near(o.0 .0, l) && near(o.0 .1, b) && near(o.0 .2, r) && near(o.0 .3, t);
132        if squares[..k].iter().any(same) {
133            continue;
134        }
135        // The square's own edges (and any re-stroke of them) are not a mark;
136        // a mark is other ink lying within the square, centred well inside.
137        let edges: Vec<usize> = squares
138            .iter()
139            .filter(|o| same(o))
140            .flat_map(|o| o.1.iter().copied())
141            .chain(own.iter().copied())
142            .collect();
143        // A square under a coloured fill of its own size is a chart legend's
144        // swatch (matplotlib strokes and fills each patch), not a checkbox.
145        let swatch = inks.iter().any(|ink| {
146            matches!(ink, Ink::Blot { .. }) && {
147                let (il, ib, ir, it) = ink.bbox();
148                let fit = 1.5;
149                (il - l).abs() <= fit
150                    && (ir - r).abs() <= fit
151                    && (ib - b).abs() <= fit
152                    && (it - t).abs() <= fit
153            }
154        });
155        if swatch {
156            continue;
157        }
158        let inset = 0.2 * (r - l);
159        let checked = inks.iter().enumerate().any(|(i, ink)| {
160            if edges.contains(&i) {
161                return false;
162            }
163            let (il, ib, ir, it) = ink.bbox();
164            let (cx, cy) = ((il + ir) / 2.0, (ib + it) / 2.0);
165            il >= l - TOL
166                && ir <= r + TOL
167                && ib >= b - TOL
168                && it <= t + TOL
169                && cx > l + inset
170                && cx < r - inset
171                && cy > b + inset
172                && cy < t - inset
173        });
174        out.push(CheckBox {
175            l: l as f32,
176            t: page_h - t as f32,
177            r: r as f32,
178            b: page_h - b as f32,
179            checked,
180        });
181    }
182    out
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    fn seg(x0: f64, y0: f64, x1: f64, y1: f64) -> Ink {
190        Ink::Seg { x0, y0, x1, y1 }
191    }
192
193    /// ReportLab's checkbox: four `m … l S` edges of a 12.96 pt square.
194    fn four_edges(x: f64, y: f64) -> Vec<Ink> {
195        let s = 12.96;
196        vec![
197            seg(x, y + s, x + s, y + s),
198            seg(x, y, x + s, y),
199            seg(x, y, x, y + s),
200            seg(x + s, y, x + s, y + s),
201        ]
202    }
203
204    #[test]
205    fn four_stroked_edges_make_one_square() {
206        let boxes = find(&four_edges(119.52, 449.04), 792.0);
207        assert_eq!(boxes.len(), 1);
208        let b = boxes[0];
209        assert!((b.l - 119.52).abs() < 0.01 && (b.r - 132.48).abs() < 0.01);
210        assert!((b.t - (792.0 - 462.0)).abs() < 0.01, "{b:?}");
211        assert!(!b.checked);
212    }
213
214    #[test]
215    fn a_stroked_rect_is_a_square_and_a_tick_inside_checks_it() {
216        let mut inks = vec![Ink::Rect {
217            l: 100.0,
218            b: 100.0,
219            r: 110.0,
220            t: 110.0,
221        }];
222        assert!(!find(&inks, 800.0)[0].checked);
223        inks.push(seg(102.0, 105.0, 104.5, 102.0));
224        inks.push(seg(104.5, 102.0, 108.5, 108.5));
225        assert!(find(&inks, 800.0)[0].checked);
226    }
227
228    #[test]
229    fn frames_bullets_and_open_shapes_are_not_checkboxes() {
230        let rect = |l, b, r, t| Ink::Rect { l, b, r, t };
231        // Too big (a frame), too small (a bullet), not square (a bar).
232        assert!(find(&[rect(0.0, 0.0, 40.0, 40.0)], 800.0).is_empty());
233        assert!(find(&[rect(0.0, 0.0, 3.0, 3.0)], 800.0).is_empty());
234        assert!(find(&[rect(0.0, 0.0, 20.0, 8.0)], 800.0).is_empty());
235        // Three edges only.
236        let mut open = four_edges(50.0, 50.0);
237        open.pop();
238        assert!(find(&open, 800.0).is_empty());
239    }
240
241    /// A legend swatch — the square filled with colour by a separate fill of
242    /// its size — is not a checkbox.
243    #[test]
244    fn a_colour_filled_square_is_a_swatch() {
245        let mut inks = four_edges(10.0, 10.0);
246        inks.push(Ink::Blot {
247            l: 10.0,
248            b: 10.0,
249            r: 22.96,
250            t: 22.96,
251        });
252        assert!(find(&inks, 800.0).is_empty());
253    }
254
255    #[test]
256    fn a_square_stroked_twice_is_one_unchecked_box() {
257        let mut inks = four_edges(10.0, 10.0);
258        inks.extend(four_edges(10.0, 10.0));
259        let boxes = find(&inks, 800.0);
260        assert_eq!(boxes.len(), 1);
261        assert!(!boxes[0].checked);
262    }
263}