pdf-docx 1.2.0

PDF to DOCX conversion with text, tables, and images
Documentation
//! Ruling lines of a page: the horizontal and vertical strokes that draw table
//! borders, read from the page content stream.

// Copyright (c) 2026 Innovation Trigger B.V.
//
// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
// the PDFluent Commercial Licence. See the LICENSE file in this repository --
// that file travels with the copy you received, which a URL does not.

use lopdf::content::Content;
use lopdf::{Document, Object, ObjectId};

/// An axis-aligned stroke in default user space (y grows upwards).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rule {
    /// Left end.
    pub x0: f64,
    /// Bottom end.
    pub y0: f64,
    /// Right end.
    pub x1: f64,
    /// Top end.
    pub y1: f64,
}

impl Rule {
    /// True when the stroke runs left to right.
    pub fn is_horizontal(&self) -> bool {
        (self.x1 - self.x0) > (self.y1 - self.y0)
    }
}

/// Strokes shorter than this are decoration (ticks, bullets), not table borders.
const MIN_RULE_LENGTH: f64 = 3.0;
/// A stroke or filled rectangle thinner than this is a line; anything thicker
/// that is only filled is a background, not a border.
const MAX_RULE_THICKNESS: f64 = 2.5;
/// A page with more strokes than this is a drawing, not a table.
const MAX_RULES: usize = 4000;

type Matrix = [f64; 6];
const IDENTITY: Matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];

fn mul(m: &Matrix, n: &Matrix) -> Matrix {
    // m applied first, then n (PDF row-vector convention).
    [
        m[0] * n[0] + m[1] * n[2],
        m[0] * n[1] + m[1] * n[3],
        m[2] * n[0] + m[3] * n[2],
        m[2] * n[1] + m[3] * n[3],
        m[4] * n[0] + m[5] * n[2] + n[4],
        m[4] * n[1] + m[5] * n[3] + n[5],
    ]
}

fn apply(m: &Matrix, x: f64, y: f64) -> (f64, f64) {
    (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5])
}

fn num(o: &Object) -> Option<f64> {
    match o {
        Object::Integer(i) => Some(*i as f64),
        Object::Real(r) => Some(f64::from(*r)),
        _ => None,
    }
}

/// One path segment collected before the paint operator decides what it is.
#[derive(Clone, Copy)]
struct Seg {
    a: (f64, f64),
    b: (f64, f64),
    /// Came from a `re` operator (so a fill of it is a background candidate).
    from_rect: bool,
}

/// Read the ruling lines of one page.
///
/// Understands `m l re h`, the paint operators `S s B b f F` (and their
/// even-odd forms) and the transform operators `q Q cm`. Curves and form
/// XObjects are ignored: a table border is a straight stroke.
pub fn page_rules(doc: &Document, page_id: ObjectId) -> Vec<Rule> {
    let data = doc.get_page_content(page_id);
    let Ok(content) = Content::decode(&data) else {
        return Vec::new();
    };

    let mut rules: Vec<Rule> = Vec::new();
    let mut ctm = IDENTITY;
    let mut stack: Vec<Matrix> = Vec::new();
    let mut path: Vec<Seg> = Vec::new();
    let mut cur: Option<(f64, f64)> = None;
    let mut start: Option<(f64, f64)> = None;

    for op in &content.operations {
        let args: Vec<f64> = op.operands.iter().filter_map(num).collect();
        match op.operator.as_str() {
            "q" => stack.push(ctm),
            "Q" => {
                if let Some(m) = stack.pop() {
                    ctm = m;
                }
            }
            "cm" if args.len() == 6 => {
                let m = [args[0], args[1], args[2], args[3], args[4], args[5]];
                ctm = mul(&m, &ctm);
            }
            "m" if args.len() == 2 => {
                let p = apply(&ctm, args[0], args[1]);
                cur = Some(p);
                start = Some(p);
            }
            "l" if args.len() == 2 => {
                let p = apply(&ctm, args[0], args[1]);
                if let Some(c) = cur {
                    path.push(Seg {
                        a: c,
                        b: p,
                        from_rect: false,
                    });
                }
                cur = Some(p);
            }
            "h" => {
                if let (Some(c), Some(s)) = (cur, start) {
                    path.push(Seg {
                        a: c,
                        b: s,
                        from_rect: false,
                    });
                    cur = Some(s);
                }
            }
            "re" if args.len() == 4 => {
                let (x, y, w, h) = (args[0], args[1], args[2], args[3]);
                let p0 = apply(&ctm, x, y);
                let p1 = apply(&ctm, x + w, y);
                let p2 = apply(&ctm, x + w, y + h);
                let p3 = apply(&ctm, x, y + h);
                for (a, b) in [(p0, p1), (p1, p2), (p2, p3), (p3, p0)] {
                    path.push(Seg {
                        a,
                        b,
                        from_rect: true,
                    });
                }
                cur = Some(p0);
                start = Some(p0);
            }
            "S" | "s" | "B" | "B*" | "b" | "b*" => {
                collect(&path, true, &mut rules);
                path.clear();
                cur = None;
            }
            "f" | "F" | "f*" => {
                collect(&path, false, &mut rules);
                path.clear();
                cur = None;
            }
            "n" => {
                path.clear();
                cur = None;
            }
            _ => {}
        }
        if rules.len() > MAX_RULES {
            return Vec::new();
        }
    }
    rules
}

fn collect(path: &[Seg], stroked: bool, out: &mut Vec<Rule>) {
    if path.is_empty() {
        return;
    }
    if !stroked {
        // A filled path draws a border only when it is a hairline rectangle.
        // The four edges of a thick filled rectangle are a background.
        if !path.iter().all(|s| s.from_rect) {
            return;
        }
        for rect in path.chunks_exact(4) {
            let xs = [rect[0].a.0, rect[1].a.0, rect[2].a.0, rect[3].a.0];
            let ys = [rect[0].a.1, rect[1].a.1, rect[2].a.1, rect[3].a.1];
            let (x0, x1) = min_max(&xs);
            let (y0, y1) = min_max(&ys);
            let (w, h) = (x1 - x0, y1 - y0);
            if h <= MAX_RULE_THICKNESS && w >= MIN_RULE_LENGTH {
                let y = (y0 + y1) / 2.0;
                out.push(Rule {
                    x0,
                    y0: y,
                    x1,
                    y1: y,
                });
            } else if w <= MAX_RULE_THICKNESS && h >= MIN_RULE_LENGTH {
                let x = (x0 + x1) / 2.0;
                out.push(Rule {
                    x0: x,
                    y0,
                    x1: x,
                    y1,
                });
            }
        }
        return;
    }
    for s in path {
        let (dx, dy) = ((s.b.0 - s.a.0).abs(), (s.b.1 - s.a.1).abs());
        if dy < 0.5 && dx >= MIN_RULE_LENGTH {
            out.push(Rule {
                x0: s.a.0.min(s.b.0),
                y0: s.a.1,
                x1: s.a.0.max(s.b.0),
                y1: s.a.1,
            });
        } else if dx < 0.5 && dy >= MIN_RULE_LENGTH {
            out.push(Rule {
                x0: s.a.0,
                y0: s.a.1.min(s.b.1),
                x1: s.a.0,
                y1: s.a.1.max(s.b.1),
            });
        }
    }
}

fn min_max(v: &[f64]) -> (f64, f64) {
    v.iter()
        .fold((f64::MAX, f64::MIN), |(lo, hi), &x| (lo.min(x), hi.max(x)))
}