pdf-engine 1.2.0

Unified PDF rendering engine — page rendering, text extraction, thumbnails.
Documentation
//! A row or a column of a table with no text in it is not one (#714).
//!
//! A ruled table draws a rule under its head and another over its total, a
//! shaded statement draws a band between two groups of lines, and a
//! statement sets a gutter between its stub and its first figure. Where the
//! rules or the gutter stand far enough apart the grid reads the gap as a
//! row or a column of its own, and the table came out with a row of empty
//! cells over its head or an empty column beside every stub. No corpus
//! this engine is measured on writes such a row or column -- FinTabNet's
//! 3,511 rows on the local sample hold not one empty row -- and TEDS
//! charged each cell of it as a cell the reader did not want.
//!
//! THE RULE, ON THE FINAL CELLS. A row whose every cell is empty, and that
//! no cell above spans into, is dropped; the rows under it move up. A
//! column whose every cell is empty, and that no cell to its left spans
//! into, is dropped the same way. A row or column a label spans into is
//! that label's and stays. A table of nothing but empty cells is left
//! alone: there is nothing to keep.

// 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 crate::blocks::{Block, BlockKind};
use crate::header_rows::tables_in;

/// Drop the empty rows and columns of every table in place.
pub(crate) fn drop_empty_lines(blocks: &mut Vec<Block>) {
    let mut drop = vec![false; blocks.len()];
    let mut changed = false;
    for (first, end) in tables_in(blocks) {
        for axis in [Axis::Row, Axis::Column] {
            let table = &blocks[first..end];
            let lines = table
                .iter()
                .filter_map(|b| axis.extent(b))
                .map(|(at, span)| at + span)
                .max()
                .unwrap_or(0);
            let mut empty = vec![true; lines as usize];
            for b in table {
                if let Some((at, span)) = axis.extent(b) {
                    for line in at..at + span {
                        if line != at || !b.text.trim().is_empty() {
                            empty[line as usize] = false;
                        }
                    }
                }
            }
            if !empty.iter().any(|&e| e) || empty.iter().all(|&e| e) {
                continue;
            }
            changed = true;
            // Line `l` of the old table is line `l - shift[l]` of the new one.
            let mut shift = vec![0u16; lines as usize];
            let mut gone = 0u16;
            for (line, is_empty) in empty.iter().enumerate() {
                if *is_empty {
                    gone += 1;
                }
                shift[line] = gone;
            }
            for (i, b) in blocks[first..end].iter_mut().enumerate() {
                let Some((at, _)) = axis.extent(b) else {
                    continue;
                };
                if empty[at as usize] {
                    drop[first + i] = true;
                } else if let BlockKind::TableCell { row, column, .. } = &mut b.kind {
                    match axis {
                        Axis::Row => *row -= shift[at as usize],
                        Axis::Column => *column -= shift[at as usize],
                    }
                }
            }
        }
    }
    if !changed {
        return;
    }
    let mut n = 0usize;
    let mut out = Vec::with_capacity(blocks.len());
    for (i, block) in blocks.drain(..).enumerate() {
        if drop[i] {
            continue;
        }
        let mut block = block;
        block.index = n;
        n += 1;
        out.push(block);
    }
    *blocks = out;
}

#[derive(Clone, Copy)]
enum Axis {
    Row,
    Column,
}

impl Axis {
    /// Where a cell starts on this axis and how many lines it covers; `None`
    /// for a block that is not a cell. A cell the row pass already dropped
    /// is still counted by the column pass, which is harmless: its text is
    /// empty, so it marks no column as kept.
    fn extent(self, block: &Block) -> Option<(u16, u16)> {
        match block.kind {
            BlockKind::TableCell {
                row,
                column,
                row_span,
                column_span,
                ..
            } => Some(match self {
                Axis::Row => (row, row_span),
                Axis::Column => (column, column_span),
            }),
            _ => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cell(row: u16, column: u16, text: &str) -> Block {
        Block {
            kind: BlockKind::TableCell {
                row,
                column,
                row_span: 1,
                column_span: 1,
                header: false,
            },
            page: 0,
            bbox: [
                100.0 * column as f64,
                700.0 - 12.0 * row as f64,
                100.0 * column as f64 + 50.0,
                710.0 - 12.0 * row as f64,
            ],
            text: text.to_string(),
            spans: Vec::new(),
            index: 0,
            continues_previous: false,
            confidence: None,
            grid: None,
            table_origin: Default::default(),
            trace_learned: None,
        }
    }

    fn grid(blocks: &[Block]) -> Vec<(u16, u16, String)> {
        blocks
            .iter()
            .filter_map(|b| match b.kind {
                BlockKind::TableCell { row, column, .. } => Some((row, column, b.text.clone())),
                _ => None,
            })
            .collect()
    }

    #[test]
    fn empty_rows_above_and_between_are_dropped() {
        let mut blocks = vec![
            cell(0, 0, ""),
            cell(0, 1, ""),
            cell(1, 0, ""),
            cell(1, 1, ""),
            cell(2, 0, "Exhibit"),
            cell(2, 1, "Form"),
            cell(3, 0, ""),
            cell(3, 1, ""),
            cell(4, 0, "4.15"),
            cell(4, 1, "8-K"),
        ];
        drop_empty_lines(&mut blocks);
        assert_eq!(
            grid(&blocks),
            vec![
                (0, 0, "Exhibit".to_string()),
                (0, 1, "Form".to_string()),
                (1, 0, "4.15".to_string()),
                (1, 1, "8-K".to_string()),
            ]
        );
        assert!(blocks.iter().enumerate().all(|(i, b)| b.index == i));
    }

    #[test]
    fn an_empty_column_beside_the_stub_is_dropped() {
        let mut blocks = vec![
            cell(0, 0, "Interest income"),
            cell(0, 1, ""),
            cell(0, 2, "$2,145"),
            cell(1, 0, "Interest expense"),
            cell(1, 1, ""),
            cell(1, 2, "526"),
        ];
        drop_empty_lines(&mut blocks);
        assert_eq!(
            grid(&blocks),
            vec![
                (0, 0, "Interest income".to_string()),
                (0, 1, "$2,145".to_string()),
                (1, 0, "Interest expense".to_string()),
                (1, 1, "526".to_string()),
            ]
        );
    }

    #[test]
    fn a_line_a_label_spans_into_stays() {
        let mut blocks = vec![
            cell(0, 0, "Item 1A."),
            cell(0, 1, "27"),
            cell(1, 1, "38"),
            cell(2, 0, ""),
            cell(2, 1, ""),
            cell(3, 0, "Item 2."),
            cell(3, 1, "39"),
        ];
        if let BlockKind::TableCell { row_span, .. } = &mut blocks[0].kind {
            *row_span = 2;
        }
        drop_empty_lines(&mut blocks);
        let g = grid(&blocks);
        assert_eq!(g.len(), 5, "{g:?}");
        assert_eq!(g[2], (1, 1, "38".to_string()));
        assert_eq!(g[3], (2, 0, "Item 2.".to_string()));
        // A head spanning two columns keeps the empty second column.
        let mut blocks = vec![
            cell(0, 0, "Years ended"),
            cell(1, 0, "a"),
            cell(1, 1, ""),
            cell(2, 0, "b"),
            cell(2, 1, ""),
        ];
        if let BlockKind::TableCell { column_span, .. } = &mut blocks[0].kind {
            *column_span = 2;
        }
        drop_empty_lines(&mut blocks);
        assert_eq!(blocks.len(), 5);
    }

    #[test]
    fn a_table_of_empty_cells_is_left_alone() {
        let mut blocks = vec![cell(0, 0, ""), cell(0, 1, ""), cell(1, 0, "")];
        drop_empty_lines(&mut blocks);
        assert_eq!(blocks.len(), 3);
    }

    #[test]
    fn two_tables_in_a_row_are_counted_apart() {
        let mut blocks = vec![
            cell(0, 0, ""),
            cell(0, 1, ""),
            cell(1, 0, "a"),
            cell(1, 1, "1"),
            cell(0, 0, "b"),
            cell(0, 1, "2"),
            cell(1, 0, ""),
            cell(1, 1, ""),
        ];
        drop_empty_lines(&mut blocks);
        assert_eq!(
            grid(&blocks),
            vec![
                (0, 0, "a".to_string()),
                (0, 1, "1".to_string()),
                (0, 0, "b".to_string()),
                (0, 1, "2".to_string()),
            ]
        );
    }
}