Skip to main content

pdf_docx/
rules.rs

1//! Ruling lines of a page: the horizontal and vertical strokes that draw table
2//! borders, read from the page content stream.
3
4// Copyright (c) 2026 Innovation Trigger B.V.
5//
6// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
7// the PDFluent Commercial Licence. See the LICENSE file in this repository --
8// that file travels with the copy you received, which a URL does not.
9
10use lopdf::content::Content;
11use lopdf::{Document, Object, ObjectId};
12
13/// An axis-aligned stroke in default user space (y grows upwards).
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct Rule {
16    /// Left end.
17    pub x0: f64,
18    /// Bottom end.
19    pub y0: f64,
20    /// Right end.
21    pub x1: f64,
22    /// Top end.
23    pub y1: f64,
24}
25
26impl Rule {
27    /// True when the stroke runs left to right.
28    pub fn is_horizontal(&self) -> bool {
29        (self.x1 - self.x0) > (self.y1 - self.y0)
30    }
31}
32
33/// Strokes shorter than this are decoration (ticks, bullets), not table borders.
34const MIN_RULE_LENGTH: f64 = 3.0;
35/// A stroke or filled rectangle thinner than this is a line; anything thicker
36/// that is only filled is a background, not a border.
37const MAX_RULE_THICKNESS: f64 = 2.5;
38/// A page with more strokes than this is a drawing, not a table.
39const MAX_RULES: usize = 4000;
40
41type Matrix = [f64; 6];
42const IDENTITY: Matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
43
44fn mul(m: &Matrix, n: &Matrix) -> Matrix {
45    // m applied first, then n (PDF row-vector convention).
46    [
47        m[0] * n[0] + m[1] * n[2],
48        m[0] * n[1] + m[1] * n[3],
49        m[2] * n[0] + m[3] * n[2],
50        m[2] * n[1] + m[3] * n[3],
51        m[4] * n[0] + m[5] * n[2] + n[4],
52        m[4] * n[1] + m[5] * n[3] + n[5],
53    ]
54}
55
56fn apply(m: &Matrix, x: f64, y: f64) -> (f64, f64) {
57    (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
58}
59
60fn num(o: &Object) -> Option<f64> {
61    match o {
62        Object::Integer(i) => Some(*i as f64),
63        Object::Real(r) => Some(f64::from(*r)),
64        _ => None,
65    }
66}
67
68/// One path segment collected before the paint operator decides what it is.
69#[derive(Clone, Copy)]
70struct Seg {
71    a: (f64, f64),
72    b: (f64, f64),
73    /// Came from a `re` operator (so a fill of it is a background candidate).
74    from_rect: bool,
75}
76
77/// Read the ruling lines of one page.
78///
79/// Understands `m l re h`, the paint operators `S s B b f F` (and their
80/// even-odd forms) and the transform operators `q Q cm`. Curves and form
81/// XObjects are ignored: a table border is a straight stroke.
82pub fn page_rules(doc: &Document, page_id: ObjectId) -> Vec<Rule> {
83    let data = doc.get_page_content(page_id);
84    let Ok(content) = Content::decode(&data) else {
85        return Vec::new();
86    };
87
88    let mut rules: Vec<Rule> = Vec::new();
89    let mut ctm = IDENTITY;
90    let mut stack: Vec<Matrix> = Vec::new();
91    let mut path: Vec<Seg> = Vec::new();
92    let mut cur: Option<(f64, f64)> = None;
93    let mut start: Option<(f64, f64)> = None;
94
95    for op in &content.operations {
96        let args: Vec<f64> = op.operands.iter().filter_map(num).collect();
97        match op.operator.as_str() {
98            "q" => stack.push(ctm),
99            "Q" => {
100                if let Some(m) = stack.pop() {
101                    ctm = m;
102                }
103            }
104            "cm" if args.len() == 6 => {
105                let m = [args[0], args[1], args[2], args[3], args[4], args[5]];
106                ctm = mul(&m, &ctm);
107            }
108            "m" if args.len() == 2 => {
109                let p = apply(&ctm, args[0], args[1]);
110                cur = Some(p);
111                start = Some(p);
112            }
113            "l" if args.len() == 2 => {
114                let p = apply(&ctm, args[0], args[1]);
115                if let Some(c) = cur {
116                    path.push(Seg {
117                        a: c,
118                        b: p,
119                        from_rect: false,
120                    });
121                }
122                cur = Some(p);
123            }
124            "h" => {
125                if let (Some(c), Some(s)) = (cur, start) {
126                    path.push(Seg {
127                        a: c,
128                        b: s,
129                        from_rect: false,
130                    });
131                    cur = Some(s);
132                }
133            }
134            "re" if args.len() == 4 => {
135                let (x, y, w, h) = (args[0], args[1], args[2], args[3]);
136                let p0 = apply(&ctm, x, y);
137                let p1 = apply(&ctm, x + w, y);
138                let p2 = apply(&ctm, x + w, y + h);
139                let p3 = apply(&ctm, x, y + h);
140                for (a, b) in [(p0, p1), (p1, p2), (p2, p3), (p3, p0)] {
141                    path.push(Seg {
142                        a,
143                        b,
144                        from_rect: true,
145                    });
146                }
147                cur = Some(p0);
148                start = Some(p0);
149            }
150            "S" | "s" | "B" | "B*" | "b" | "b*" => {
151                collect(&path, true, &mut rules);
152                path.clear();
153                cur = None;
154            }
155            "f" | "F" | "f*" => {
156                collect(&path, false, &mut rules);
157                path.clear();
158                cur = None;
159            }
160            "n" => {
161                path.clear();
162                cur = None;
163            }
164            _ => {}
165        }
166        if rules.len() > MAX_RULES {
167            return Vec::new();
168        }
169    }
170    rules
171}
172
173fn collect(path: &[Seg], stroked: bool, out: &mut Vec<Rule>) {
174    if path.is_empty() {
175        return;
176    }
177    if !stroked {
178        // A filled path draws a border only when it is a hairline rectangle.
179        // The four edges of a thick filled rectangle are a background.
180        if !path.iter().all(|s| s.from_rect) {
181            return;
182        }
183        for rect in path.chunks_exact(4) {
184            let xs = [rect[0].a.0, rect[1].a.0, rect[2].a.0, rect[3].a.0];
185            let ys = [rect[0].a.1, rect[1].a.1, rect[2].a.1, rect[3].a.1];
186            let (x0, x1) = min_max(&xs);
187            let (y0, y1) = min_max(&ys);
188            let (w, h) = (x1 - x0, y1 - y0);
189            if h <= MAX_RULE_THICKNESS && w >= MIN_RULE_LENGTH {
190                let y = (y0 + y1) / 2.0;
191                out.push(Rule {
192                    x0,
193                    y0: y,
194                    x1,
195                    y1: y,
196                });
197            } else if w <= MAX_RULE_THICKNESS && h >= MIN_RULE_LENGTH {
198                let x = (x0 + x1) / 2.0;
199                out.push(Rule {
200                    x0: x,
201                    y0,
202                    x1: x,
203                    y1,
204                });
205            }
206        }
207        return;
208    }
209    for s in path {
210        let (dx, dy) = ((s.b.0 - s.a.0).abs(), (s.b.1 - s.a.1).abs());
211        if dy < 0.5 && dx >= MIN_RULE_LENGTH {
212            out.push(Rule {
213                x0: s.a.0.min(s.b.0),
214                y0: s.a.1,
215                x1: s.a.0.max(s.b.0),
216                y1: s.a.1,
217            });
218        } else if dx < 0.5 && dy >= MIN_RULE_LENGTH {
219            out.push(Rule {
220                x0: s.a.0,
221                y0: s.a.1.min(s.b.1),
222                x1: s.a.0,
223                y1: s.a.1.max(s.b.1),
224            });
225        }
226    }
227}
228
229fn min_max(v: &[f64]) -> (f64, f64) {
230    v.iter()
231        .fold((f64::MAX, f64::MIN), |(lo, hi), &x| (lo.min(x), hi.max(x)))
232}